From 733ef4093b18049e1d6b7d6b8f7b912f09a8fa54 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Thu, 19 Feb 2026 16:02:06 +0100 Subject: [PATCH 001/129] refactor: migrate from tox/PyScaffold to uv for dependency management, consolidating all dependencies into pyproject.toml and a single uv.lock file. This simplifies and speeds up the development setup greatly. Changes: - Switch build backend from setuptools to hatchling - Remove setup.cfg, tox.ini, .isort.cfg and setup.py in favour of .flake8 and pyproject.toml - Add Poethepoet tasks - Upgrade main Python version (CI/CD, .python-version, etc.) to 3.12 - Deprecate Python 3.9 - Add .python-version for consistent Python version management - Create separate Dockerfile for flexmeasures-client - Replace pip-tools with uv in all CI/CD workflows - Remove ci/run_mypy.sh in favour of Poethepoet task - Update documentation Signed-off-by: Stijn van Houwelingen --- .flake8 | 10 + .github/agents/test-specialist.md | 50 +- .github/workflows/ci.yml | 172 +-- .github/workflows/release.yml | 69 ++ .gitignore | 1 - .isort.cfg | 3 - .python-version | 1 + Dockerfile | 64 ++ ci/run_mypy.sh | 10 - docker-compose.yml | 12 +- docs/conf.py | 6 +- docs/requirements.txt | 5 - pyproject.toml | 105 +- setup.cfg | 137 --- setup.py | 22 - tox.ini | 99 -- uv.lock | 1653 +++++++++++++++++++++++++++++ 17 files changed, 1985 insertions(+), 434 deletions(-) create mode 100644 .flake8 create mode 100644 .github/workflows/release.yml delete mode 100644 .isort.cfg create mode 100644 .python-version create mode 100644 Dockerfile delete mode 100755 ci/run_mypy.sh delete mode 100644 docs/requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py delete mode 100644 tox.ini create mode 100644 uv.lock diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..58448390 --- /dev/null +++ b/.flake8 @@ -0,0 +1,10 @@ +[flake8] +max_line_length = 88 +extend_ignore = E203, E501, W503 +# ^ Black-compatible +# E203, E501 and W503 have edge cases handled by black +exclude = + build + dist + .eggs + docs/conf.py diff --git a/.github/agents/test-specialist.md b/.github/agents/test-specialist.md index 7aeb528a..b8bd106f 100644 --- a/.github/agents/test-specialist.md +++ b/.github/agents/test-specialist.md @@ -55,45 +55,71 @@ When writing tests for this project, follow these patterns: ## Code Quality and Linting -Before finalizing tests, always apply the project's code quality checks: +Before finalizing tests, always apply the project's code quality checks. + +### Poe Tasks +The project uses [poethepoet](https://poethepoet.natn.io/) for common tasks. Prefer these over running tools directly: + +```bash +uv run poe lint # Run all pre-commit hooks on all files +uv run poe type-check # Run mypy on files with type hints +uv run poe test # Run the full test suite +uv run poe test-no-s2 # Run tests excluding S2 +uv run poe test-s2 # Run S2 tests only +``` ### Running Pre-commit Hooks -The project uses `.pre-commit-config.yaml` to enforce code quality standards. Always run pre-commit hooks before committing: +The project uses `.pre-commit-config.yaml` to enforce code quality standards. `pre-commit` is included in the `dev` dependency group, so no separate installation is needed: ```bash -# Install pre-commit (if not already installed) -pip install pre-commit +# If you have not installed pre-commit already: +uv tool install pre-commit # Run all pre-commit hooks on all files -pre-commit run --all-files +uv run pre-commit run --all-files + +# Or via the poe task +uv run poe lint ``` ### Pre-commit Hooks in This Project The following hooks are configured: - **trailing-whitespace**: Removes trailing whitespace -- **end-of-file-fixer**: Ensures files end with a newline +- **check-added-large-files**: Prevents committing large files - **check-ast**: Validates Python syntax - **check-json**: Validates JSON files +- **check-merge-conflict**: Detects merge conflict markers +- **check-xml**: Validates XML files - **check-yaml**: Validates YAML files - **debug-statements**: Detects debug statements +- **end-of-file-fixer**: Ensures files end with a newline +- **requirements-txt-fixer**: Sorts requirements files +- **mixed-line-ending**: Normalises line endings - **isort**: Sorts Python imports - **black**: Formats Python code (line length, style) - **flake8**: Checks Python code style and quality -- **mypy**: Performs static type checking + +### Type Checking +Mypy is run separately from pre-commit via the poe task: + +```bash +uv run poe type-check +``` + +When mypy reports errors: +- Add type hints where needed +- Use `# type: ignore` comments sparingly for known issues ### Fixing Linting Issues When pre-commit hooks fail: 1. Review the output to understand what failed -2. Many hooks auto-fix issues (black, isort, end-of-file-fixer) - re-run to verify +2. Many hooks auto-fix issues (black, isort, end-of-file-fixer) — re-run to verify 3. For manual fixes (flake8 errors): - Address unused imports, undefined names, line too long, etc. - Run pre-commit again to verify fixes -4. For mypy type errors: - - Add type hints where needed - - Use `# type: ignore` comments sparingly for known issues ### Best Practices -- Run pre-commit hooks frequently during development +- Run `uv run poe lint` frequently during development - Fix linting issues before requesting code review - Keep test code clean and well-formatted like production code - Ensure all hooks pass before pushing changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f936f754..5dd91102 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,10 @@ -# GitHub Actions configuration **EXAMPLE**, -# MODIFY IT ACCORDING TO YOUR NEEDS! -# Reference: https://docs.github.com/en/actions - -name: tests +name: lint-and-test on: push: - # Avoid using all the resources/limits available by checking only - # relevant branches and tags. Other branches can be checked via PRs. branches: [main, hotfix/hackathon] - tags: - - 'v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+' # Match tags that resemble a version - - 'v[0-9]+\.[0-9]+\.[0-9]+' # Match tags that resemble a version - pull_request: # Run in every PR - workflow_dispatch: # Allow manually triggering the workflow - schedule: - # Run roughly every 15 days at 00:00 UTC - # (useful to check if updates on dependencies break the package) - - cron: "0 0 1,16 * *" + pull_request: + workflow_dispatch: concurrency: group: >- @@ -26,132 +13,61 @@ concurrency: cancel-in-progress: true jobs: - prepare: + check: runs-on: ubuntu-latest - outputs: - wheel-distribution: ${{ steps.wheel-distribution.outputs.path }} + name: Check (lint) steps: - - uses: actions/checkout@v3 - with: { fetch-depth: 0 } # deep clone for setuptools-scm - - uses: actions/setup-python@v4 - id: setup-python - with: { python-version: "3.11" } - - name: Run static analysis and format checkers - run: pipx run pre-commit run --all-files --show-diff-on-failure - - name: Build package distribution files - run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox -e clean,build - - name: Record the path of wheel distribution - id: wheel-distribution - run: echo "path=$(ls dist/*.whl)" >> $GITHUB_OUTPUT - - name: Store the distribution files for use in other stages - # `tests` and `publish` will use the same pre-built distributions, - # so we make sure to release the exact same package that was tested - uses: actions/upload-artifact@v4 + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - name: python-distribution-files - path: dist/ - retention-days: 1 + enable-cache: true + uv-version: "0.10" + python-version-file: ".python-version" + - uses: pre-commit/action@v3.0.1 test: - needs: prepare + needs: check + runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python: - - "3.10" - - "3.11" - - "3.12" - platform: - - ubuntu-latest - - macos-latest -# - windows-latest - runs-on: ${{ matrix.platform }} + python: ["3.10", "3.11", "3.12"] + name: "Test (on Python ${{ matrix.python }})" steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - id: setup-python + - uses: actions/checkout@v4 with: - python-version: ${{ matrix.python }} - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 + fetch-depth: 0 # needed for hatch-vcs versioning + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - name: python-distribution-files - path: dist/ + enable-cache: true + python-version: ${{ matrix.python }} + - name: Install dependencies + run: uv sync --frozen --group test - name: Run tests - run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' - -- -rFEx --durations 10 --color yes # pytest args - - name: Run tests (s2) - run: >- - pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox -e s2 --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' - -- -rFEx --durations 10 --color yes # pytest args - # - name: Generate coverage report - # run: pipx run coverage lcov -o coverage.lcov - # - name: Upload partial coverage report - # uses: coverallsapp/github-action@master - # with: - # path-to-lcov: coverage.lcov - # github-token: ${{ secrets.GITHUB_TOKEN }} - # flag-name: ${{ matrix.platform }} - py${{ matrix.python }} - # parallel: true + run: uv run pytest tests --ignore=tests/s2 -rFEx --durations 10 --color yes - finalize: - needs: test + test-s2: + needs: check runs-on: ubuntu-latest + name: "Test S2 (on Python 3.12)" steps: - - run: echo "Finished checks" - - publish: - needs: finalize - if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/v') }} - runs-on: ubuntu-latest - environment: - name: release - url: https://pypi.org/project/flexmeasures-client/ - permissions: - id-token: write - steps: - - uses: actions/checkout@v3 - with: {fetch-depth: 0} # deep clone for setuptools-scm - - uses: actions/setup-python@v4 - with: { python-version: "3.11" } - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 + - uses: actions/checkout@v4 with: - name: python-distribution-files - path: dist/ - - name: Publish Package - uses: pypa/gh-action-pypi-publish@release/v1 - # run: pipx run tox -e publish - - name: Publish release on GitHub - uses: softprops/action-gh-release@v2 + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - generate_release_notes: true - tag_name: ${{ inputs.custom_version || github.ref_name }} - env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }} + enable-cache: true + python-version-file: ".python-version" + - name: Install dependencies + run: uv sync --frozen --extra s2 --group test + - name: Run S2 tests + run: uv run pytest tests/s2 -rFEx --durations 10 --color yes - # test-publish: - # needs: finalize - # if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/test') }} - # runs-on: ubuntu-latest - # environment: - # name: testpypi - # url: https://test.pypi.org/project/flexmeasures-client/ - # permissions: - # id-token: write - # steps: - # - uses: actions/checkout@v3 - # - uses: actions/setup-python@v4 - # with: {python-version: "3.11"} - # - name: Retrieve pre-built distribution files - # uses: actions/download-artifact@v3 - # with: {name: python-distribution-files, path: dist/} - # - name: Publish Package - # uses: pypa/gh-action-pypi-publish@release/v1 - # with: - # repository-url: https://test.pypi.org/legacy/ - # # run: pipx run tox -e publish + finalize: + needs: [test, test-s2] + runs-on: ubuntu-latest + steps: + - run: echo "Finished checks" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..857b48b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: release + +on: + push: + tags: + - 'v[0-9]+\.[0-9]+\.[0-9]+' + - 'v[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+' + +permissions: + contents: write + +jobs: + release-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + uv-version: "0.10" + python-version-file: ".python-version" + enable-cache: true + + - name: Build release distributions + run: uv build + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: release-dists + path: dist/ + + pypi-publish: + runs-on: ubuntu-latest + needs: release-build + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + + # Dedicated environments with protections for publishing are strongly recommended. + # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules + environment: + name: pypi + url: https://pypi.org/project/flexmeasures-client/ + + steps: + - name: Retrieve release distributions + uses: actions/download-artifact@v4 + with: + name: release-dists + path: dist/ + + - name: Publish release distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + + github-release: + runs-on: ubuntu-latest + needs: pypi-publish + steps: + - name: Publish release on GitHub + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + tag_name: ${{ inputs.custom_version || github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }} diff --git a/.gitignore b/.gitignore index be5b7d9e..81a97133 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,6 @@ MANIFEST # Per-project virtualenvs .venv*/ .conda*/ -.python-version venv/* log diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 0a105ec9..00000000 --- a/.isort.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[settings] -profile = black -known_first_party = flexmeasures_client diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..862c98a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +ARG UV_MAJOR_VERSION=0.10 +ARG PYTHON_VERSION=3.12 +ARG DEBIAN_VERSION=trixie + +# Build the virtual environment using UV +FROM ghcr.io/astral-sh/uv:${UV_MAJOR_VERSION}-python${PYTHON_VERSION}-${DEBIAN_VERSION}-slim AS builder + +# Redeclare ARG after FROM to make it available in this stage +ARG UV_COMPILE_BYTECODE=1 + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 +ENV DEBIAN_FRONTEND=noninteractive +ENV UV_COMPILE_BYTECODE=${UV_COMPILE_BYTECODE} +ENV UV_LINK_MODE=copy + +# Install git for hatch-vcs version detection +RUN apt-get update && apt-get install -y --no-install-recommends \ + git && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Sync dependencies without installing the project itself (creates .venv) +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --all-extras --no-install-project + +# Ensure subsequent commands use the virtual environment +ENV VIRTUAL_ENV=/app/.venv \ + PATH="/app/.venv/bin:$PATH" + +# Copy application code (including .git for version detection) +COPY pyproject.toml uv.lock README.rst ./ +COPY src/ ./src +COPY .git ./.git + +# Install FlexMeasures itself in the virtual environment +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --all-extras + +# Use a separate runtime image to run the code +FROM python:${PYTHON_VERSION}-slim-${DEBIAN_VERSION} AS runtime + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 +ENV DEBIAN_FRONTEND=noninteractive + +WORKDIR /app + +ENV VIRTUAL_ENV=/app/.venv \ + PATH="/app/.venv/bin:$PATH" + +# Copy virtual environment from builder +COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} + +# Copy application code +COPY --from=builder /app/src ./src + +# Set environment variables to optimize Python +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ No newline at end of file diff --git a/ci/run_mypy.sh b/ci/run_mypy.sh deleted file mode 100755 index e9f9412a..00000000 --- a/ci/run_mypy.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -set -e -pip install --upgrade 'mypy>=0.902' -pip install types-pytz types-requests types-Flask types-click types-redis types-tzlocal types-python-dateutil types-setuptools types-tabulate types-PyYAML -# We are checking python files which have type hints, and leave out bigger issues we made issues for -# * data/scripts: We'll remove legacy code: https://trello.com/c/1wEnHOkK/7-remove-custom-data-scripts -# * data/models and data/services: https://trello.com/c/rGxZ9h2H/540-makequery-call-signature-is-incoherent - files=$(find src \ - -name \*.py | xargs grep -l "from typing import") -mypy --follow-imports skip --ignore-missing-imports $files diff --git a/docker-compose.yml b/docker-compose.yml index 8068b475..16fbf2a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,6 @@ services: build: context: . dockerfile: Dockerfile - depends_on: - - server restart: always ports: - "8080:8080" # aiohttp default; adjust if you change it @@ -26,10 +24,10 @@ services: LOGGING_LEVEL: INFO volumes: # If flexmeasures_client lives in your repo and you want live edits - - ../flexmeasures-client:/app/flexmeasures-client:rw + - flexmeasures-client:/app/flexmeasures-client:rw entrypoint: ["/bin/sh", "-c"] command: - - | - # pip install --break-system-packages --no-cache-dir "git+https://github.com/FlexMeasures/flexmeasures-client.git@main#egg=flexmeasures-client[s2]" - pip install --break-system-packages -e /app/flexmeasures-client[s2] - python3 /app/flexmeasures-client/src/flexmeasures_client/s2/script/websockets_server.py + - python3 /app/src/flexmeasures_client/s2/script/websockets_server.py + +volumes: + flexmeasures-client: \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 3f65d0ca..2742a1d1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,9 +24,9 @@ # This hack is necessary since RTD does not issue `sphinx-apidoc` before running # `sphinx-build -b html . _build/html`. See Issue: # https://github.com/readthedocs/readthedocs.org/issues/1139 -# DON'T FORGET: Check the box "Install your project inside a virtualenv using -# setup.py install" in the RTD Advanced Settings. -# Additionally it helps us to avoid running apidoc manually +# Additionally it helps us to avoid running apidoc manually. +# Docs dependencies are declared in the `docs` dependency group in pyproject.toml. +# Install with: uv sync --group docs try: # for Sphinx >= 1.7 from sphinx.ext import apidoc diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 2ddf98af..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Requirements file for ReadTheDocs, check .readthedocs.yml. -# To build the module reference correctly, make sure every external package -# under `install_requires` in `setup.cfg` is also listed here! -sphinx>=3.2.1 -# sphinx_rtd_theme diff --git a/pyproject.toml b/pyproject.toml index 92b887e3..db37c95d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,103 @@ [build-system] -# AVOID CHANGING REQUIRES: IT WILL BE UPDATED BY PYSCAFFOLD! -requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5"] -build-backend = "setuptools.build_meta" +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" -[tool.setuptools_scm] -# For smarter version schemes and other configuration options, -# check out https://github.com/pypa/setuptools_scm -version_scheme = "no-guess-dev" +[project] +name = "flexmeasures-client" +description = "Async client to connect to the FlexMeasures API" +readme = "README.rst" +requires-python = ">=3.10, <3.13" +license = "Apache-2.0" +license-files = ["LICENSE.txt"] +authors = [ + {name = "Flexmeasures", email = "info@seita.nl"} +] +keywords = ["flexmeasures", "energy", "flexibility", "scheduling", "smart grid"] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dynamic = ["version"] +dependencies = [ + "aiohttp>=3.13.3", + "pandas>=2.1.4", + "async-timeout>=5.0.1", + "packaging>=26.0", +] + +[project.urls] +Homepage = "https://github.com/FlexMeasures/flexmeasures-client" +Documentation = "https://flexmeasures-client.readthedocs.io/" +"Source code" = "https://github.com/FlexMeasures/flexmeasures-client" + +[project.optional-dependencies] +s2 = [ + "requests>=2.32.5", + "s2-python>=0.8.1", + "semver>=3.0.4", + "tzdata>=2025.3", +] + +[dependency-groups] +test = [ + "aioresponses>=0.7.8", + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] +dev = [ + "mypy>=0.902", + "poethepoet>=0.41.0", + "pre-commit>=4.5.1", + "types-pytz>=2025.2.0.20251108", + "types-requests>=2.32.4.20260107", +] +docs = [ + "sphinx>=3.2.1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/flexmeasures_client"] + +[tool.hatch.version] +source = "vcs" + +[tool.poe.tasks.test] +help = "Run the full test suite." +cmd = "pytest" + +[tool.poe.tasks.test-no-s2] +help = "Run the test suite (excluding S2)." +cmd = "pytest --ignore=tests/s2" + +[tool.poe.tasks.test-s2] +help = "Run S2 tests." +cmd = "pytest tests/s2" + +[tool.poe.tasks.type-check] +help = "Run mypy on files with type hints." +shell = """ + files=$(find src -name \\*.py | xargs grep -l "from typing import") + mypy --follow-imports skip --ignore-missing-imports $files +""" + +[tool.poe.tasks.lint] +help = "Run pre-commit hooks on all files." +cmd = "pre-commit run --all-files" + +[tool.pytest] +# https://github.com/zupo/awesome-pytest-speedup?tab=readme-ov-file +testpaths = ["tests"] +addopts = ["--strict-markers", "--import-mode=importlib"] [tool.black] line-length = 88 + +[tool.isort] +profile = "black" +known_first_party = ["flexmeasures_client"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 30a1bbd4..00000000 --- a/setup.cfg +++ /dev/null @@ -1,137 +0,0 @@ -# This file is used to configure your project. -# Read more about the various options under: -# https://setuptools.pypa.io/en/latest/userguide/declarative_config.html -# https://setuptools.pypa.io/en/latest/references/keywords.html - -[metadata] -name = flexmeasures-client -description = Async client to connect to the FlexMeasures API -author = Flexmeasures -author_email = info@seita.nl -license = APACHE -license_files = LICENSE.txt -long_description = file: README.rst -long_description_content_type = text/x-rst; charset=UTF-8 -url = https://github.com/FlexMeasures/flexmeasures-client -# Add here related links, for example: -project_urls = - # Documentation = https://pyscaffold.org/ -# Source = https://github.com/pyscaffold/pyscaffold/ -# Changelog = https://pyscaffold.org/en/latest/changelog.html -# Tracker = https://github.com/pyscaffold/pyscaffold/issues -# Conda-Forge = https://anaconda.org/conda-forge/pyscaffold -# Download = https://pypi.org/project/PyScaffold/#files -# Twitter = https://twitter.com/PyScaffold - -# Change if running only on Windows, Mac or Linux (comma-separated) -platforms = any - -# Add here all kinds of additional classifiers as defined under -# https://pypi.org/classifiers/ -classifiers = - Development Status :: 4 - Beta - Programming Language :: Python - - -[options] -zip_safe = False -packages = find_namespace: -include_package_data = True -package_dir = - =src - -# Require a min/specific Python version (comma-separated conditions) -python_requires >= 3.9 - -# Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. -# Version specifiers like >=2.2,<3.0 avoid problems due to API changes in -# new major versions. This works if the required packages follow Semantic Versioning. -# For more information, check out https://semver.org/. -install_requires = - importlib-metadata; python_version<"3.8" - aiohttp - pandas>=2.1.4 - async_timeout - packaging - -[options.packages.find] -where = src -exclude = - tests - -[options.extras_require] -# Add here additional requirements for extra features, to install with: -# `pip install flexmeasures-client[s2]` like: -s2= - requests - s2-python - semver - tzdata - -# Add here test requirements (semicolon/line-separated) -testing = - setuptools - pytest - pytest-cov - pytest-asyncio - pytest-mock - aioresponses>=0.7.8 - -[options.entry_points] -# Add here console scripts like: -# console_scripts = -# script_name = flexmeasures_client.module:function -# For example: -# console_scripts = -# fibonacci = flexmeasures_client.skeleton:run -# And any other entry points, for example: -# pyscaffold.cli = -# awesome = pyscaffoldext.awesome.extension:AwesomeExtension - - -[tool:pytest] -# Specify command line options as you would do when invoking pytest directly. -# e.g. --cov-report html (or xml) for html/xml output or --junitxml junit.xml -# in order to write a coverage file that can be read by Jenkins. -# CAUTION: --cov flags may prohibit setting breakpoints while debugging. -# Comment those flags to avoid this pytest issue. -# addopts = -# --cov flexmeasures_client --cov-report term-missing -# --verbose -# norecursedirs = -# dist -# build -# .tox -testpaths = tests -# Use pytest markers to select/deselect specific tests -# markers = -# slow: mark tests as slow (deselect with '-m "not slow"') -# system: mark end-to-end system tests - -[devpi:upload] -# Options for the devpi: PyPI server and packaging tool -# VCS export must be deactivated since we are using setuptools-scm -no_vcs = 1 -formats = bdist_wheel - -[flake8] -# Some sane defaults for the code style checker flake8 -max_line_length = 88 -extend_ignore = E203, E501, W503 -# ^ Black-compatible -# E203, E501 and W503 have edge cases handled by black -exclude = - .tox - build - dist - .eggs - docs/conf.py - -[pyscaffold] -# PyScaffold's parameters when the project was created. -# This will be used when updating. Do not change! -version = 4.4 -package = flexmeasures_client -extensions = - github_actions - pre_commit diff --git a/setup.py b/setup.py deleted file mode 100644 index 81744491..00000000 --- a/setup.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Setup file for flexmeasures-client. -Use setup.cfg to configure your project. - -This file was generated with PyScaffold 4.4. -PyScaffold helps you to put up the scaffold of your new Python project. -Learn more under: https://pyscaffold.org/ -""" - -from setuptools import setup - -if __name__ == "__main__": - try: - setup(use_scm_version={"version_scheme": "no-guess-dev"}) - except: # noqa - print( - "\n\nAn error occurred while building the project, " - "please ensure you have the most updated version of setuptools, " - "setuptools_scm and wheel with:\n" - " pip install -U setuptools setuptools_scm wheel\n\n" - ) - raise diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 443c45e8..00000000 --- a/tox.ini +++ /dev/null @@ -1,99 +0,0 @@ -# Tox configuration file -# Read more under https://tox.wiki/ -# THIS SCRIPT IS SUPPOSED TO BE AN EXAMPLE. MODIFY IT ACCORDING TO YOUR NEEDS! - -[tox] -minversion = 3.24 -envlist = default -isolated_build = True - - -[testenv] -description = Invoke pytest to run automated tests -setenv = - TOXINIDIR = {toxinidir} -passenv = - HOME - SETUPTOOLS_* -extras = - testing -commands = - pytest {posargs} --ignore=tests/s2 - -[testenv:s2] -deps = - .[s2] # Install the package with optional dependencies -commands = pytest tests/s2 - - - -# # To run `tox -e lint` you need to make sure you have a -# # `.pre-commit-config.yaml` file. See https://pre-commit.com -# [testenv:lint] -# description = Perform static analysis and style checks -# skip_install = True -# deps = pre-commit -# passenv = -# HOMEPATH -# PROGRAMDATA -# SETUPTOOLS_* -# commands = -# pre-commit run --all-files {posargs:--show-diff-on-failure} - - -[testenv:{build,clean}] -description = - build: Build the package in isolation according to PEP517, see https://github.com/pypa/build - clean: Remove old distribution files and temporary build artifacts (./build and ./dist) -# https://setuptools.pypa.io/en/stable/build_meta.html#how-to-use-it -skip_install = True -changedir = {toxinidir} -deps = - build: build[virtualenv] -passenv = - SETUPTOOLS_* -commands = - clean: python -c 'import shutil; [shutil.rmtree(p, True) for p in ("build", "dist", "docs/_build")]' - clean: python -c 'import pathlib, shutil; [shutil.rmtree(p, True) for p in pathlib.Path("src").glob("*.egg-info")]' - build: python -m build {posargs} -# By default, both `sdist` and `wheel` are built. If your sdist is too big or you don't want -# to make it available, consider running: `tox -e build -- --wheel` - - -[testenv:{docs,doctests,linkcheck}] -description = - docs: Invoke sphinx-build to build the docs - doctests: Invoke sphinx-build to run doctests - linkcheck: Check for broken links in the documentation -passenv = - SETUPTOOLS_* -setenv = - DOCSDIR = {toxinidir}/docs - BUILDDIR = {toxinidir}/docs/_build - docs: BUILD = html - doctests: BUILD = doctest - linkcheck: BUILD = linkcheck -deps = - -r {toxinidir}/docs/requirements.txt - # ^ requirements.txt shared with Read The Docs -commands = - sphinx-build --color -b {env:BUILD} -d "{env:BUILDDIR}/doctrees" "{env:DOCSDIR}" "{env:BUILDDIR}/{env:BUILD}" {posargs} - - -[testenv:publish] -description = - Publish the package you have been developing to a package index server. - By default, it uses testpypi. If you really want to publish your package - to be publicly accessible in PyPI, use the `-- --repository pypi` option. -skip_install = True -changedir = {toxinidir} -passenv = - # See: https://twine.readthedocs.io/en/latest/ - TWINE_USERNAME - TWINE_PASSWORD - TWINE_REPOSITORY - TWINE_REPOSITORY_URL -deps = twine -commands = - python -m twine check dist/* - python -m twine upload {posargs:--repository {env:TWINE_REPOSITORY:testpypi}} dist/* diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..800ff0dc --- /dev/null +++ b/uv.lock @@ -0,0 +1,1653 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.13" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, + { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, + { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, + { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, +] + +[[package]] +name = "aioresponses" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/03/532bbc645bdebcf3b6af3b25d46655259d66ce69abba7720b71ebfabbade/aioresponses-0.7.8.tar.gz", hash = "sha256:b861cdfe5dc58f3b8afac7b0a6973d5d7b2cb608dd0f6253d16b8ee8eaf6df11", size = 40253, upload-time = "2025-01-19T18:14:03.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b7/584157e43c98aa89810bc2f7099e7e01c728ecf905a66cf705106009228f/aioresponses-0.7.8-py2.py3-none-any.whl", hash = "sha256:b73bd4400d978855e55004b23a3a84cb0f018183bcf066a85ad392800b5b9a94", size = 12518, upload-time = "2025-01-19T18:13:59.633Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.24.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, +] + +[[package]] +name = "flexmeasures-client" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "async-timeout" }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.optional-dependencies] +s2 = [ + { name = "requests" }, + { name = "s2-python" }, + { name = "semver" }, + { name = "tzdata" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "poethepoet" }, + { name = "pre-commit" }, + { name = "types-pytz" }, + { name = "types-requests" }, +] +docs = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +test = [ + { name = "aioresponses" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.13.3" }, + { name = "async-timeout", specifier = ">=5.0.1" }, + { name = "packaging", specifier = ">=26.0" }, + { name = "pandas", specifier = ">=2.1.4" }, + { name = "requests", marker = "extra == 's2'", specifier = ">=2.32.5" }, + { name = "s2-python", marker = "extra == 's2'", specifier = ">=0.8.1" }, + { name = "semver", marker = "extra == 's2'", specifier = ">=3.0.4" }, + { name = "tzdata", marker = "extra == 's2'", specifier = ">=2025.3" }, +] +provides-extras = ["s2"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=0.902" }, + { name = "poethepoet", specifier = ">=0.41.0" }, + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "types-pytz", specifier = ">=2025.2.0.20251108" }, + { name = "types-requests", specifier = ">=2.32.4.20260107" }, +] +docs = [{ name = "sphinx", specifier = ">=3.2.1" }] +test = [ + { name = "aioresponses", specifier = ">=0.7.8" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" }, + { url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, + { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, +] + +[[package]] +name = "pastel" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "poethepoet" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pastel" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/b9/fa92286560f70eaa40d473ea48376d20c6c21f63627d33c6bb1c5e385175/poethepoet-0.41.0.tar.gz", hash = "sha256:dcaad621dc061f6a90b17d091bebb9ca043d67bfe9bd6aa4185aea3ebf7ff3e6", size = 87780, upload-time = "2026-02-08T20:45:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5e/0b83e0222ce5921b3f9081eeca8c6fb3e1cfd5ca0d06338adf93b28ce061/poethepoet-0.41.0-py3-none-any.whl", hash = "sha256:4bab9fd8271664c5d21407e8f12827daeb6aa484dc6cc7620f0c3b4e62b42ee4", size = 113590, upload-time = "2026-02-08T20:45:34.697Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534, upload-time = "2025-10-08T19:46:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526, upload-time = "2025-10-08T19:46:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263, upload-time = "2025-10-08T19:46:05.405Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012, upload-time = "2025-10-08T19:46:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491, upload-time = "2025-10-08T19:46:08.909Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319, upload-time = "2025-10-08T19:46:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856, upload-time = "2025-10-08T19:46:12.003Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241, upload-time = "2025-10-08T19:46:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552, upload-time = "2025-10-08T19:46:14.938Z" }, + { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113, upload-time = "2025-10-08T19:46:16.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778, upload-time = "2025-10-08T19:46:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047, upload-time = "2025-10-08T19:46:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093, upload-time = "2025-10-08T19:46:20.643Z" }, + { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638, upload-time = "2025-10-08T19:46:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229, upload-time = "2025-10-08T19:46:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "s2-python" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/dc/7c9f0537e574ee5fbd81d77f57951d728f8e08dcc580fb6cc2532b1d4180/s2_python-0.8.1.tar.gz", hash = "sha256:62cc6f7b98c57357c5d2c0cfdbe5fa9c9ee0951883645a30d5d4f3d9ae40b4a3", size = 81379, upload-time = "2025-11-26T14:02:36.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/be/6fcbc1ed2170db732079f80111e676e6c53018f4e762ea52d5f2cfd2b62d/s2_python-0.8.1-py3-none-any.whl", hash = "sha256:14608126b381bc793155f593de8688cf3d30d98893fe92f76db722e170b510be", size = 66860, upload-time = "2025-11-26T14:02:35.055Z" }, +] + +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "types-pytz" +version = "2025.2.0.20251108" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/ff/c047ddc68c803b46470a357454ef76f4acd8c1088f5cc4891cdd909bfcf6/types_pytz-2025.2.0.20251108.tar.gz", hash = "sha256:fca87917836ae843f07129567b74c1929f1870610681b4c92cb86a3df5817bdb", size = 10961, upload-time = "2025-11-08T02:55:57.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c1/56ef16bf5dcd255155cc736d276efa6ae0a5c26fd685e28f0412a4013c01/types_pytz-2025.2.0.20251108-py3-none-any.whl", hash = "sha256:0f1c9792cab4eb0e46c52f8845c8f77cf1e313cb3d68bf826aa867fe4717d91c", size = 10116, upload-time = "2025-11-08T02:55:56.194Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/03/a94d404ca09a89a7301a7008467aed525d4cdeb9186d262154dd23208709/virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7", size = 5864558, upload-time = "2026-02-19T07:48:02.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, + { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, + { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, + { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, + { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] From d66105692a19fe2584da38a6efc789a39459a469 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 23 Feb 2026 14:43:39 +0100 Subject: [PATCH 002/129] fix: fix whitespace issues Signed-off-by: Stijn van Houwelingen --- .github/workflows/release.yml | 2 +- Dockerfile | 2 +- docker-compose.yml | 2 +- docs/conf.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 857b48b9..fe318b93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write - + # Dedicated environments with protections for publishing are strongly recommended. # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules environment: diff --git a/Dockerfile b/Dockerfile index 862c98a8..1374c38a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,4 +61,4 @@ COPY --from=builder /app/src ./src # Set environment variables to optimize Python ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ No newline at end of file + PYTHONUNBUFFERED=1 diff --git a/docker-compose.yml b/docker-compose.yml index 16fbf2a0..02d12369 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,4 +30,4 @@ services: - python3 /app/src/flexmeasures_client/s2/script/websockets_server.py volumes: - flexmeasures-client: \ No newline at end of file + flexmeasures-client: diff --git a/docs/conf.py b/docs/conf.py index 2742a1d1..537c1212 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,8 +8,8 @@ # serve to show the default. import os -import sys import shutil +import sys # -- Path setup -------------------------------------------------------------- From cef07f3a1dc05caad700f4d9d90d175f2885363c Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 23 Feb 2026 14:46:19 +0100 Subject: [PATCH 003/129] feat: move from pre-commit git repos to using uv for pre-commit scripts Signed-off-by: Stijn van Houwelingen --- .github/workflows/ci.yml | 14 +++++-- .pre-commit-config.yaml | 35 +++++++++------- pyproject.toml | 3 ++ uv.lock | 86 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dd91102..143a2370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,16 +15,22 @@ concurrency: jobs: check: runs-on: ubuntu-latest - name: Check (lint) + name: Check (on Python 3.12) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + - name: "Set up Python" + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true + cache-dependency-glob: '.pre-commit-config.yaml' uv-version: "0.10" - python-version-file: ".python-version" - - uses: pre-commit/action@v3.0.1 + - name: Install dev dependencies + run: uv sync --only-group dev --frozen + - uses: tox-dev/action-pre-commit-uv@v1 test: needs: check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8cc92f3f..206147c0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,16 +35,22 @@ repos: # --remove-unused-variables, # ] -- repo: https://github.com/PyCQA/isort - rev: 5.12.0 +- repo: local hooks: - - id: isort + - id: isort + name: isort + language: system + entry: uv run isort . + require_serial: true + types_or: [cython, pyi, python] -- repo: https://github.com/psf/black - rev: 25.1.0 +- repo: local hooks: - - id: black - language_version: python3 + - id: black + name: black (code formatting) + language: system + entry: uv run black + types: [python] ## If like to embrace black styles even in the docs: # - repo: https://github.com/asottile/blacken-docs @@ -53,12 +59,13 @@ repos: # - id: blacken-docs # additional_dependencies: [black] -- repo: https://github.com/PyCQA/flake8 - rev: 7.3.0 +- repo: local hooks: - - id: flake8 - ## You can add flake8 plugins via `additional_dependencies`: - # additional_dependencies: [flake8-bugbear] + - id: flake8 + name: flake8 (code linting) + language: system + entry: uv run flake8 + types: [python] ## Check for misspells in documentation files: # - repo: https://github.com/codespell-project/codespell @@ -71,6 +78,6 @@ repos: - id: mypy name: mypy (static typing) pass_filenames: false - language: script - entry: ci/run_mypy.sh + language: system + entry: uv run poe type-check verbose: true diff --git a/pyproject.toml b/pyproject.toml index db37c95d..c6e57495 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,9 @@ test = [ "pytest-mock>=3.15.1", ] dev = [ + "black>=25.1.0", + "flake8>=7.3.0", + "isort>=5.12.0", "mypy>=0.902", "poethepoet>=0.41.0", "pre-commit>=4.5.1", diff --git a/uv.lock b/uv.lock index 800ff0dc..7eafbebf 100644 --- a/uv.lock +++ b/uv.lock @@ -169,6 +169,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419, upload-time = "2025-01-29T05:37:06.642Z" }, + { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080, upload-time = "2025-01-29T05:37:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886, upload-time = "2025-01-29T04:18:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404, upload-time = "2025-01-29T04:19:04.296Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -382,6 +412,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, ] +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + [[package]] name = "flexmeasures-client" source = { editable = "." } @@ -403,6 +447,9 @@ s2 = [ [package.dev-dependencies] dev = [ + { name = "black" }, + { name = "flake8" }, + { name = "isort" }, { name = "mypy" }, { name = "poethepoet" }, { name = "pre-commit" }, @@ -437,6 +484,9 @@ provides-extras = ["s2"] [package.metadata.requires-dev] dev = [ + { name = "black", specifier = ">=25.1.0" }, + { name = "flake8", specifier = ">=7.3.0" }, + { name = "isort", specifier = ">=5.12.0" }, { name = "mypy", specifier = ">=0.902" }, { name = "poethepoet", specifier = ">=0.41.0" }, { name = "pre-commit", specifier = ">=4.5.1" }, @@ -545,6 +595,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isort" +version = "5.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/c4/dc00e42c158fc4dda2afebe57d2e948805c06d5169007f1724f0683010a9/isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", size = 174643, upload-time = "2023-01-28T17:10:22.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/63/4036ae70eea279c63e2304b91ee0ac182f467f24f86394ecfe726092340b/isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6", size = 91198, upload-time = "2023-01-28T17:10:21.149Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -644,6 +703,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, ] +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -1056,6 +1124,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1147,6 +1224,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + [[package]] name = "pygments" version = "2.19.2" From d63afe5684d7e7eb97120335d6f81db2d1906b1b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 13:27:41 +0100 Subject: [PATCH 004/129] dev: try using the _sending_queue instead Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index ecbdc8fa..83d6c866 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -290,7 +290,7 @@ async def handle_handshake(self, message: Handshake): # TODO: check the version that the RM is using and send a # `selected_protocol_version` that matches the one of the RM # TODO: Return a TBD "CloseConnection" message to close the connection - await self.send_message(get_reception_status(message, ReceptionStatusValues.OK)) + await self._sending_queue.put(get_reception_status(message, ReceptionStatusValues.OK)) latest_compatible_version = get_latest_compatible_version( message.supported_protocol_versions, From 564b8f1193fb0236040ee8d30a81bb7716620d42 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:00:53 +0100 Subject: [PATCH 005/129] Revert "dev: try using the _sending_queue instead" This reverts commit d63afe5684d7e7eb97120335d6f81db2d1906b1b. --- src/flexmeasures_client/s2/cem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 83d6c866..ecbdc8fa 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -290,7 +290,7 @@ async def handle_handshake(self, message: Handshake): # TODO: check the version that the RM is using and send a # `selected_protocol_version` that matches the one of the RM # TODO: Return a TBD "CloseConnection" message to close the connection - await self._sending_queue.put(get_reception_status(message, ReceptionStatusValues.OK)) + await self.send_message(get_reception_status(message, ReceptionStatusValues.OK)) latest_compatible_version = get_latest_compatible_version( message.supported_protocol_versions, From 2db7a4f5dbb439bb3693c3f81ffa66d2b707d42a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 14:22:17 +0100 Subject: [PATCH 006/129] fix: give the sending task enough time to flush the queue before the connection shuts down Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index ecbdc8fa..b39da1c1 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -291,6 +291,7 @@ async def handle_handshake(self, message: Handshake): # `selected_protocol_version` that matches the one of the RM # TODO: Return a TBD "CloseConnection" message to close the connection await self.send_message(get_reception_status(message, ReceptionStatusValues.OK)) + await asyncio.sleep(0.5) latest_compatible_version = get_latest_compatible_version( message.supported_protocol_versions, From 35cc9add109290b20d7be8849aa773944f5c19e0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 14:58:34 +0100 Subject: [PATCH 007/129] dev: use DEBUG logging level Signed-off-by: F.N. Claessen --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8068b475..051c038c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: FLEXMEASURES_BASE_URL: http://server:5000 FLEXMEASURES_USER: toy-user@flexmeasures.io FLEXMEASURES_PASSWORD: toy-password - LOGGING_LEVEL: INFO + LOGGING_LEVEL: DEBUG volumes: # If flexmeasures_client lives in your repo and you want live edits - ../flexmeasures-client:/app/flexmeasures-client:rw From 375b49ce9482f6cf7fdc849dfd9d4bde8c0911dc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:00:01 +0100 Subject: [PATCH 008/129] feat: await message sent rather than message queued Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 25 ++++++++++++------- .../s2/script/websockets_server.py | 10 +++++--- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index b39da1c1..a87c8017 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -59,7 +59,7 @@ class CEM(Handler): ] # maps the CommodityQuantity power measurement sensors to FM sensor IDs _fm_client: FlexMeasuresClient - _sending_queue: Queue[pydantic.BaseModel] + _sending_queue: asyncio.Queue[tuple[pydantic.BaseModel, asyncio.Future]] _timers: dict[str, datetime] _datastore: dict @@ -216,7 +216,7 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): ) if response is not None: - await self._sending_queue.put(response) + await self.send_message(response) def update_control_type(self, control_type: ControlType): """ @@ -232,14 +232,20 @@ async def get_message(self) -> str: str: message in JSON format """ - message = await self._sending_queue.get() - await asyncio.sleep(0.3) + item = await self._sending_queue.get() + + if not isinstance(item, tuple) or len(item) != 2: + raise RuntimeError( + "Invalid item in sending queue. All messages must go through send_message() rather than _sending_queue.put()." + ) + + message, fut = item # Pending for pydantic V2 to implement model.model_dump(mode="json") in # PR #1409 (https://github.com/pydantic/pydantic/issues/1409) message = json.loads(message.json()) - return message + return message, fut async def activate_control_type( self, control_type: ControlType @@ -279,8 +285,7 @@ async def activate_control_type( ].register_success_callbacks( message_id, self.update_control_type, control_type=control_type ) - - await self._sending_queue.put( + await self.send_message( SelectControlType(message_id=message_id, control_type=control_type) ) return None @@ -291,7 +296,6 @@ async def handle_handshake(self, message: Handshake): # `selected_protocol_version` that matches the one of the RM # TODO: Return a TBD "CloseConnection" message to close the connection await self.send_message(get_reception_status(message, ReceptionStatusValues.OK)) - await asyncio.sleep(0.5) latest_compatible_version = get_latest_compatible_version( message.supported_protocol_versions, @@ -426,8 +430,11 @@ def handle_revoke_object(self, message: RevokeObject): return get_reception_status(message, ReceptionStatusValues.OK) async def send_message(self, message): + loop = asyncio.get_running_loop() + fut = loop.create_future() self._logger.debug(f"Sent: {message}") - await self._sending_queue.put(message) + await self._sending_queue.put((message, fut)) + await fut # wait until actually sent def get_commodity_unit(commodity_quantity) -> str: diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index afceeb56..8c223237 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -47,9 +47,13 @@ async def websocket_producer(ws, cem: CEM): cem._logger.debug("start websocket message producer") cem._logger.debug(f"IS CLOSED? {cem.is_closed()}") while not cem.is_closed(): - message = await cem.get_message() - cem._logger.debug("sending message") - await ws.send_json(message) + message, fut = await cem.get_message() + try: + cem._logger.debug("sending message") + await ws.send_json(message) + fut.set_result(True) + except Exception as exc: + fut.set_exception(exc) cem._logger.debug("cem closed") From 14f77924f25e235031c9a752cf04c55b897236f5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:00:19 +0100 Subject: [PATCH 009/129] fix: also close WS if closing CEM Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 8c223237..cd3a27ff 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -75,6 +75,7 @@ async def websocket_consumer(ws, cem: CEM): elif msg.type == aiohttp.WSMsgType.ERROR: cem._logger.debug("close...") cem.close() + await ws.close() cem._logger.error(f"ws connection closed with exception {ws.exception()}") # TODO: save cem state? From 47995853ab9914c4b47cd7856874af964a619c18 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:27:29 +0100 Subject: [PATCH 010/129] feat: skip setting up the toy account Signed-off-by: F.N. Claessen --- docker-compose.yml => docker-compose.override.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) rename docker-compose.yml => docker-compose.override.yml (79%) diff --git a/docker-compose.yml b/docker-compose.override.yml similarity index 79% rename from docker-compose.yml rename to docker-compose.override.yml index 051c038c..25273db2 100644 --- a/docker-compose.yml +++ b/docker-compose.override.yml @@ -4,11 +4,18 @@ # run this from the flexmeasures folder (which contains the Dockerfile): # docker compose \ # -f docker-compose.yml \ -# -f ../flexmeasures-client/docker-compose.yml \ +# -f ../flexmeasures-client/docker-compose.override.yml \ # up # ------------------------------------------------------------------ services: + server: + command: + - | + pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt + flexmeasures db upgrade + # toy account step removed + gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:applicationservices: cem: build: context: . From 89d433800d3d21ec8eb1058b3dd58a9a1ae3090f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:30:44 +0100 Subject: [PATCH 011/129] feat: post prices in background task Signed-off-by: F.N. Claessen --- .../s2/script/websockets_server.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index cd3a27ff..9a10af37 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -169,12 +169,17 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) - await fm_client.post_sensor_data( - sensor_id=price_sensor["id"], - start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 - duration="P3D", # P1M - values=[0.3], - unit="EUR/kWh", + + # Continue immediately without awaiting + LOGGER.debug("Posting 3 days of prices in a background task..") + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=price_sensor["id"], + start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 + duration="P3D", # P1M + values=[0.3], + unit="EUR/kWh", + ) ) if power_sensor is None: power_sensor = await fm_client.add_sensor( From 60ff6af74133ac12547e8508f89072d3eef3d661 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:33:19 +0100 Subject: [PATCH 012/129] Revert "feat: skip setting up the toy account" This reverts commit 47995853ab9914c4b47cd7856874af964a619c18. --- docker-compose.override.yml => docker-compose.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) rename docker-compose.override.yml => docker-compose.yml (79%) diff --git a/docker-compose.override.yml b/docker-compose.yml similarity index 79% rename from docker-compose.override.yml rename to docker-compose.yml index 25273db2..051c038c 100644 --- a/docker-compose.override.yml +++ b/docker-compose.yml @@ -4,18 +4,11 @@ # run this from the flexmeasures folder (which contains the Dockerfile): # docker compose \ # -f docker-compose.yml \ -# -f ../flexmeasures-client/docker-compose.override.yml \ +# -f ../flexmeasures-client/docker-compose.yml \ # up # ------------------------------------------------------------------ services: - server: - command: - - | - pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt - flexmeasures db upgrade - # toy account step removed - gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:applicationservices: cem: build: context: . From 404ed6b373dc66cddad8c7de31888acebc833896 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:54:37 +0100 Subject: [PATCH 013/129] chore: use modern method name Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 6e81f291..691c8dc7 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -62,7 +62,7 @@ def now(self): return self._timezone.localize(datetime.now()) async def send_storage_status(self, status: FRBCStorageStatus): - await self._fm_client.post_measurements( + await self._fm_client.post_sensor_data( self._soc_sensor_id, start=self.now(), values=[status.present_fill_level], @@ -82,7 +82,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): dt = status.transition_timestamp # self.now() - await self._fm_client.post_measurements( + await self._fm_client.post_sensor_data( self._rm_discharge_sensor_id, start=dt, values=[-power], From 5d4de1070fe156a9abe1e9530c039a56f9591ccf Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:55:07 +0100 Subject: [PATCH 014/129] fix: fall back on now in case FRBCActuatorStatus.transition_timestamp is missing Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 691c8dc7..f9244837 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -80,7 +80,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): + (fill_rate.end_of_range - fill_rate.start_of_range) * factor ) - dt = status.transition_timestamp # self.now() + dt = status.transition_timestamp or self.now() await self._fm_client.post_sensor_data( self._rm_discharge_sensor_id, From f8ea65849cdd492a8ccb16b43e5f28f74d7df058 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:57:21 +0100 Subject: [PATCH 015/129] fix: actuator status unit Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index f9244837..72af00b8 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -45,6 +45,8 @@ def __init__( schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, valid_from_shift: timedelta = timedelta(days=1), + power_unit: str = "kW", + energy_unit: str = "kWh", ) -> None: super().__init__(max_size) self._power_sensor_id = power_sensor_id @@ -57,6 +59,8 @@ def __init__( # delay the start of the schedule from the time `valid_from` # of the FRBC.SystemDescription. self._valid_from_shift = valid_from_shift + self.power_unit = power_unit + self.energy_unit = energy_unit def now(self): return self._timezone.localize(datetime.now()) @@ -66,7 +70,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): self._soc_sensor_id, start=self.now(), values=[status.present_fill_level], - unit="MWh", + unit=self.energy_unit, duration=timedelta(minutes=1), ) @@ -86,7 +90,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): self._rm_discharge_sensor_id, start=dt, values=[-power], - unit="MWh", + unit=self.power_unit, duration=timedelta(minutes=15), ) From d59f3407a8d10ac2de5717fe9d7bea0aaa32052c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 15:27:29 +0100 Subject: [PATCH 016/129] feat: skip setting up the toy account Signed-off-by: F.N. Claessen --- docker-compose.yml => docker-compose.override.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) rename docker-compose.yml => docker-compose.override.yml (79%) diff --git a/docker-compose.yml b/docker-compose.override.yml similarity index 79% rename from docker-compose.yml rename to docker-compose.override.yml index 051c038c..25273db2 100644 --- a/docker-compose.yml +++ b/docker-compose.override.yml @@ -4,11 +4,18 @@ # run this from the flexmeasures folder (which contains the Dockerfile): # docker compose \ # -f docker-compose.yml \ -# -f ../flexmeasures-client/docker-compose.yml \ +# -f ../flexmeasures-client/docker-compose.override.yml \ # up # ------------------------------------------------------------------ services: + server: + command: + - | + pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt + flexmeasures db upgrade + # toy account step removed + gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:applicationservices: cem: build: context: . From 6abd2a11deac510c9f246044048e0af94ea491d5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:02:41 +0100 Subject: [PATCH 017/129] fix: copy-paste mistake Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 25273db2..e562a60c 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -15,7 +15,7 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt flexmeasures db upgrade # toy account step removed - gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:applicationservices: + gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:application cem: build: context: . From 5328de99f14f5797cf96105eb4f97a00b0f7a30a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:05:13 +0100 Subject: [PATCH 018/129] fix: discharge unit Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 9a10af37..6d6e7550 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -201,7 +201,7 @@ async def configure_site( rm_discharge_sensor = await fm_client.add_sensor( name="RM discharge", event_resolution="PT15M", - unit="dimensionless", + unit="kW", generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) From eae232bc3c4a930b71b07a340421e91621b4de60 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:06:33 +0100 Subject: [PATCH 019/129] docs: update instruction to run docker-compose stack Signed-off-by: F.N. Claessen --- docs/CEM.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CEM.rst b/docs/CEM.rst index 72661c75..98fe41d5 100644 --- a/docs/CEM.rst +++ b/docs/CEM.rst @@ -15,14 +15,14 @@ Then point your Resource Managers (RMs) to ``http://localhost:8080/ws`` and run: python3 flexmeasures_client/s2/scripts/websockets_server.py -We also included a ``docker-compose.yaml`` that can be used to set up the CEM including the FlexMeasures server, creating a fully self-hosted HEMS. +We also included a ``docker-compose.override.yaml`` that can be used to set up the CEM including the FlexMeasures server, creating a fully self-hosted HEMS. Assuming your ``flexmeasures`` and ``flexmeasures-client`` repo folders are located side by side, run this from your flexmeasures folder: .. code-block:: bash docker compose \ -f docker-compose.yml \ - -f ../flexmeasures-client/docker-compose.yml \ + -f ../flexmeasures-client/docker-compose.override.yml \ up From 0e72c25776b4c25d8790cb8356cff42786032fbf Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:13:12 +0100 Subject: [PATCH 020/129] Revert "fix: discharge unit" This reverts commit 5328de99f14f5797cf96105eb4f97a00b0f7a30a. --- src/flexmeasures_client/s2/script/websockets_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 6d6e7550..9a10af37 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -201,7 +201,7 @@ async def configure_site( rm_discharge_sensor = await fm_client.add_sensor( name="RM discharge", event_resolution="PT15M", - unit="kW", + unit="dimensionless", generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) From abb0a7436643faeaa0bd493e34fbedd4c291ec57 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:13:38 +0100 Subject: [PATCH 021/129] fix: schedule power sensor instead of dimensionless discharge sensor Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 72af00b8..8a1c3353 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -87,7 +87,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): dt = status.transition_timestamp or self.now() await self._fm_client.post_sensor_data( - self._rm_discharge_sensor_id, + self._power_sensor_id, start=dt, values=[-power], unit=self.power_unit, From bbaac73bfaa9e8e83a7a10f39c6a9a94864771b6 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:39:45 +0100 Subject: [PATCH 022/129] feat: trigger schedule with each storage status (not yet rate limited) and make system_description_id optional Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 8a1c3353..dfb70b8b 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -73,6 +73,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): unit=self.energy_unit, duration=timedelta(minutes=1), ) + await self.trigger_schedule() async def send_actuator_status(self, status: FRBCActuatorStatus): factor = status.operation_mode_factor @@ -94,12 +95,15 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): duration=timedelta(minutes=15), ) - async def trigger_schedule(self, system_description_id: str): + async def trigger_schedule(self, system_description_id: str | None = None): """Translates S2 System Description into FM API calls""" - system_description: FRBCSystemDescription = self._system_description_history[ - system_description_id - ] + if system_description_id: + system_description: FRBCSystemDescription = self._system_description_history[ + system_description_id + ] + else: + system_description: FRBCSystemDescription = list(self._system_description_history.values())[-1] if len(self._storage_status_history) > 0: soc_at_start = list(self._storage_status_history.values())[ From 374e44aff236795522dffcdf077af979020b4117 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:39:59 +0100 Subject: [PATCH 023/129] feat: post 1 year of data in background tasks Signed-off-by: F.N. Claessen --- .../s2/script/websockets_server.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 9a10af37..10076db2 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -171,16 +171,17 @@ async def configure_site( ) # Continue immediately without awaiting - LOGGER.debug("Posting 3 days of prices in a background task..") - asyncio.create_task( - fm_client.post_sensor_data( - sensor_id=price_sensor["id"], - start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 - duration="P3D", # P1M - values=[0.3], - unit="EUR/kWh", + LOGGER.debug("Posting 1 year of prices in monthly background tasks..") + for m in range(12): + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=price_sensor["id"], + start=f"2026-{m+ 1:02}-01T00:00+01", # 2026-01-01T00:00+01 + m months + duration="P31D", + values=[0.3], + unit="EUR/kWh", + ) ) - ) if power_sensor is None: power_sensor = await fm_client.add_sensor( name="power", From a1d6ba749c0e39ebded28967f71168908aad3b55 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:40:08 +0100 Subject: [PATCH 024/129] Revert "feat: post 1 year of data in background tasks" This reverts commit 374e44aff236795522dffcdf077af979020b4117. --- .../s2/script/websockets_server.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 10076db2..9a10af37 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -171,17 +171,16 @@ async def configure_site( ) # Continue immediately without awaiting - LOGGER.debug("Posting 1 year of prices in monthly background tasks..") - for m in range(12): - asyncio.create_task( - fm_client.post_sensor_data( - sensor_id=price_sensor["id"], - start=f"2026-{m+ 1:02}-01T00:00+01", # 2026-01-01T00:00+01 + m months - duration="P31D", - values=[0.3], - unit="EUR/kWh", - ) + LOGGER.debug("Posting 3 days of prices in a background task..") + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=price_sensor["id"], + start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 + duration="P3D", # P1M + values=[0.3], + unit="EUR/kWh", ) + ) if power_sensor is None: power_sensor = await fm_client.add_sensor( name="power", From f8e0d621efa883b23db7612277693d0dfdcc95da Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:42:16 +0100 Subject: [PATCH 025/129] fix: set prior knowledge of prices and test with now Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 9a10af37..a22d8780 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -175,7 +175,8 @@ async def configure_site( asyncio.create_task( fm_client.post_sensor_data( sensor_id=price_sensor["id"], - start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 + start="2026-02-25T00:00+01", # now + prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 duration="P3D", # P1M values=[0.3], unit="EUR/kWh", From be6d8d30176b21ab651e118682f8b70699cf7d31 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:52:16 +0100 Subject: [PATCH 026/129] fix: floor the schedule start Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index dfb70b8b..5336cfef 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -116,9 +116,10 @@ async def trigger_schedule(self, system_description_id: str | None = None): soc_min, soc_max = get_soc_min_max(system_description) # call schedule + start = system_description.valid_from + self._valid_from_shift # TODO: localize datetime + start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) schedule = await self._fm_client.trigger_and_get_schedule( - start=system_description.valid_from - + self._valid_from_shift, # TODO: localize datetime + start=start, sensor_id=self._power_sensor_id, flex_context={ "production-price": {"sensor": self._price_sensor_id}, From 5334c0c083f9281f3f6f7d9fd634b1b198f319bc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 16:54:10 +0100 Subject: [PATCH 027/129] fix: messages should now be routed through cem.send_message Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 5336cfef..0131ac87 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -144,4 +144,4 @@ async def trigger_schedule(self, system_description_id: str | None = None): # put instructions to sending queue for instruction in instructions: - await self._sending_queue.put(instruction) + await self.send_message(instruction) From 2845036cade18fee7f2d139c34719b448f1bf52b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 17:01:56 +0100 Subject: [PATCH 028/129] fix: messages should now be routed through cem.send_message; update handlers accordingly Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 4 ++-- src/flexmeasures_client/s2/control_types/FRBC/__init__.py | 2 +- src/flexmeasures_client/s2/control_types/__init__.py | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index a87c8017..46bc7f99 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -159,8 +159,8 @@ def register_control_type(self, control_type_handler: ControlTypeHandler): # add fm_client to control_type handler control_type_handler._fm_client = self._fm_client - # add sending queue - control_type_handler._sending_queue = self._sending_queue + # add send_message method so the handler can send messages + control_type_handler.send_message = self.send_message # Add logger control_type_handler._logger = self._logger diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index 2b4ccf15..94c85b43 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -196,4 +196,4 @@ async def trigger_schedule(self, system_description_id: str): ) # put instruction into the sending queue - await self._sending_queue.put(instruction) + await self.send_message(instruction) diff --git a/src/flexmeasures_client/s2/control_types/__init__.py b/src/flexmeasures_client/s2/control_types/__init__.py index b3e19f5b..7fc6137c 100644 --- a/src/flexmeasures_client/s2/control_types/__init__.py +++ b/src/flexmeasures_client/s2/control_types/__init__.py @@ -1,8 +1,7 @@ from __future__ import annotations -from asyncio import Queue from logging import Logger -from typing import cast +from typing import cast, Callable from pydantic import BaseModel from s2python.common import ( @@ -22,7 +21,7 @@ class ControlTypeHandler(Handler): _instruction_history: SizeLimitOrderedDict[str, BaseModel] _instruction_status_history: SizeLimitOrderedDict[str, InstructionStatus] _fm_client: FlexMeasuresClient - _sending_queue: Queue + send_message: Callable _logger: Logger def __init__(self, max_size: int = 100) -> None: From e01176d9e8c72860f7c619b7e73177c53001de5b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 17:12:39 +0100 Subject: [PATCH 029/129] feat: roll 3 days of test prices Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index a22d8780..e5a3103e 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -2,6 +2,8 @@ import json import logging import os +from datetime import datetime +from zoneinfo import ZoneInfo import aiohttp from aiohttp import web @@ -172,10 +174,11 @@ async def configure_site( # Continue immediately without awaiting LOGGER.debug("Posting 3 days of prices in a background task..") + start_of_today = datetime.now(ZoneInfo("Europe/Amsterdam")).replace(hour=0, minute=0, second=0, microsecond=0).isoformat() asyncio.create_task( fm_client.post_sensor_data( sensor_id=price_sensor["id"], - start="2026-02-25T00:00+01", # now + start=start_of_today, prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 duration="P3D", # P1M values=[0.3], From c780f9899e2dd9a3ff255c417757e907c026ee7a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 17:37:30 +0100 Subject: [PATCH 030/129] style: black, isort Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 14 +++++++++----- .../s2/control_types/__init__.py | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 0131ac87..1a3afbe7 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -99,11 +99,13 @@ async def trigger_schedule(self, system_description_id: str | None = None): """Translates S2 System Description into FM API calls""" if system_description_id: - system_description: FRBCSystemDescription = self._system_description_history[ - system_description_id - ] + system_description: FRBCSystemDescription = ( + self._system_description_history[system_description_id] + ) else: - system_description: FRBCSystemDescription = list(self._system_description_history.values())[-1] + system_description: FRBCSystemDescription = list( + self._system_description_history.values() + )[-1] if len(self._storage_status_history) > 0: soc_at_start = list(self._storage_status_history.values())[ @@ -116,7 +118,9 @@ async def trigger_schedule(self, system_description_id: str | None = None): soc_min, soc_max = get_soc_min_max(system_description) # call schedule - start = system_description.valid_from + self._valid_from_shift # TODO: localize datetime + start = ( + system_description.valid_from + self._valid_from_shift + ) # TODO: localize datetime start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) schedule = await self._fm_client.trigger_and_get_schedule( start=start, diff --git a/src/flexmeasures_client/s2/control_types/__init__.py b/src/flexmeasures_client/s2/control_types/__init__.py index 7fc6137c..429ad419 100644 --- a/src/flexmeasures_client/s2/control_types/__init__.py +++ b/src/flexmeasures_client/s2/control_types/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from logging import Logger -from typing import cast, Callable +from typing import Callable, cast from pydantic import BaseModel from s2python.common import ( From 26f4778349dd7334d251987a6a518a7eb8962505 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 25 Feb 2026 17:48:03 +0100 Subject: [PATCH 031/129] style: black Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index e5a3103e..7a08d025 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -174,7 +174,11 @@ async def configure_site( # Continue immediately without awaiting LOGGER.debug("Posting 3 days of prices in a background task..") - start_of_today = datetime.now(ZoneInfo("Europe/Amsterdam")).replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + start_of_today = ( + datetime.now(ZoneInfo("Europe/Amsterdam")) + .replace(hour=0, minute=0, second=0, microsecond=0) + .isoformat() + ) asyncio.create_task( fm_client.post_sensor_data( sensor_id=price_sensor["id"], From 5fb3a262d11659262294988969f101d6f2258687 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 26 Feb 2026 13:37:26 +0100 Subject: [PATCH 032/129] feat: make schedules appear with consumption on the positive axis Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 7a08d025..a56c6eac 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -196,6 +196,7 @@ async def configure_site( unit="kW", generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", + attributes={"consumption_is_positive": True}, ) if soc_sensor is None: soc_sensor = await fm_client.add_sensor( From 508611b631bfed4114191efbed0854b772c16c94 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 26 Feb 2026 13:39:57 +0100 Subject: [PATCH 033/129] fix: update _sending_queue.put to send_message in FillRateBasedControlTUNES, too Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_tunes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py index 6b822791..2c647209 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py @@ -215,7 +215,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): subject_message_id=status.message_id, status=ReceptionStatusValues.PERMANENT_ERROR, ) - await self._sending_queue.put(response) + await self.send_message(response) await self.trigger_schedule() async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): @@ -246,7 +246,7 @@ async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): subject_message_id=leakage.message_id, status=ReceptionStatusValues.PERMANENT_ERROR, ) - await self._sending_queue.put(response) + await self.send_message(response) async def send_actuator_status(self, status: FRBCActuatorStatus): if not self._is_timer_due("actuator_status"): @@ -514,12 +514,12 @@ async def trigger_schedule(self): object_id=message_id, ) self._logger.debug(f"Sending revoke instruction for {message_id}") - await self._sending_queue.put(revoke_instruction) + await self.send_message(revoke_instruction) self._datastore["instructions"] = {} # Put the instruction in the sending queue for instruction in instructions: - await self._sending_queue.put(instruction) + await self.send_message(instruction) # Store instructions for instruction in instructions: From 07b55c51f2ddc1cc7b2e1c66c24847b4705e77be Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 09:15:11 +0100 Subject: [PATCH 034/129] feat: port send_fill_level_target_profile Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 51 +++++++++++++++++++ .../s2/script/websockets_server.py | 28 +++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 1a3afbe7..e8533c69 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -7,9 +7,12 @@ import pytz +import pandas as pd + try: from s2python.frbc import ( FRBCActuatorStatus, + FRBCFillLevelTargetProfile, FRBCStorageStatus, FRBCSystemDescription, ) @@ -25,6 +28,9 @@ fm_schedule_to_instructions, get_soc_min_max, ) +from flexmeasures_client.s2.control_types.translations import ( + translate_fill_level_target_profile, +) class FRBCSimple(FRBC): @@ -41,6 +47,8 @@ def __init__( soc_sensor_id: int, rm_discharge_sensor_id: int, price_sensor_id: int, + soc_minima_sensor_id: int, + soc_maxima_sensor_id: int, timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, @@ -54,6 +62,8 @@ def __init__( self._schedule_duration = schedule_duration self._soc_sensor_id = soc_sensor_id self._rm_discharge_sensor_id = rm_discharge_sensor_id + self._soc_minima_sensor_id = soc_minima_sensor_id + self._soc_maxima_sensor_id = soc_maxima_sensor_id self._timezone = pytz.timezone(timezone) # delay the start of the schedule from the time `valid_from` @@ -135,6 +145,8 @@ async def trigger_schedule(self, system_description_id: str | None = None): "soc-at-start": soc_at_start, # TODO: use forecast of the SOC instead "soc-min": soc_min, "soc-max": soc_max, + "soc-minima": {"sensor": self._soc_minima_sensor_id}, + "soc-maxima": {"sensor": self._soc_maxima_sensor_id}, }, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, @@ -149,3 +161,42 @@ async def trigger_schedule(self, system_description_id: str | None = None): # put instructions to sending queue for instruction in instructions: await self.send_message(instruction) + + async def send_fill_level_target_profile( + self, fill_level_target_profile: FRBCFillLevelTargetProfile + ): + """ + Send FRBC.FillLevelTargetProfile to FlexMeasures. + + Args: + fill_level_target_profile (FRBCFillLevelTargetProfile): The fill level target profile to be translated and sent. + """ + # if not self._is_timer_due("fill_level_target_profile"): + # return + RESOLUTION = "15min" + + soc_minima, soc_maxima = translate_fill_level_target_profile( + fill_level_target_profile=fill_level_target_profile, + resolution=RESOLUTION, + fill_level_scale=1, + ) + + duration = str(pd.Timedelta(RESOLUTION) * len(soc_maxima)) + + # POST SOC Minima measurements to FlexMeasures + await self._fm_client.post_sensor_data( + sensor_id=self._soc_minima_sensor_id, + start=fill_level_target_profile.start_time, + values=soc_minima.tolist(), + unit=self.energy_unit, + duration=duration, + ) + + # POST SOC Maxima measurements to FlexMeasures + await self._fm_client.post_sensor_data( + sensor_id=self._soc_maxima_sensor_id, + start=fill_level_target_profile.start_time, + values=soc_maxima.tolist(), + unit=self.energy_unit, + duration=duration, + ) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index a56c6eac..6bf6e57b 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -93,7 +93,7 @@ async def websocket_handler(request): "toy-password", "toy-user@flexmeasures.io", host="server:5000" ) - price_sensor, power_sensor, soc_sensor, rm_discharge_sensor = await configure_site( + price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor = await configure_site( site_name, fm_client ) @@ -107,6 +107,8 @@ async def websocket_handler(request): price_sensor_id=price_sensor["id"], soc_sensor_id=soc_sensor["id"], rm_discharge_sensor_id=rm_discharge_sensor["id"], + soc_minima_sensor_id=soc_minima_sensor["id"], + soc_maxima_sensor_id=soc_maxima_sensor["id"], ) cem.register_control_type(frbc) @@ -153,6 +155,8 @@ async def configure_site( power_sensor = None soc_sensor = None rm_discharge_sensor = None + soc_minima_sensor = None + soc_maxima_sensor = None for sensor in sensors: if sensor["name"] == "price": price_sensor = sensor @@ -162,6 +166,10 @@ async def configure_site( soc_sensor = sensor elif sensor["name"] == "RM discharge": rm_discharge_sensor = sensor + elif sensor["name"] == "soc-minima": + soc_minima_sensor = sensor + elif sensor["name"] == "soc-maxima": + soc_maxima_sensor = sensor if price_sensor is None: price_sensor = await fm_client.add_sensor( @@ -214,7 +222,23 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) - return price_sensor, power_sensor, soc_sensor, rm_discharge_sensor + if soc_minima_sensor is None: + soc_minima_sensor = await fm_client.add_sensor( + name="soc-minima", + event_resolution="PT15M", + unit="kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if soc_maxima_sensor is None: + soc_maxima_sensor = await fm_client.add_sensor( + name="soc-maxima", + event_resolution="PT15M", + unit="kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + return price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor app = web.Application() From 872224c5036bd6ba1a3f563db5ab4bdd4597bc15 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 09:16:43 +0100 Subject: [PATCH 035/129] feat: save scheduled state-of-charge, too Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index e8533c69..81213c88 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -147,6 +147,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): "soc-max": soc_max, "soc-minima": {"sensor": self._soc_minima_sensor_id}, "soc-maxima": {"sensor": self._soc_maxima_sensor_id}, + "state-of-charge": {"sensor": self._soc_sensor_id}, }, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, From 0b96cc5f9d86bb4c7d31ace4d9042ef06aa58715 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:19:38 +0100 Subject: [PATCH 036/129] feat: set sensors_to_show on CEM asset Signed-off-by: F.N. Claessen --- .../s2/script/websockets_server.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 6bf6e57b..b55a67db 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -128,7 +128,7 @@ async def configure_site( account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) - site_asset = None + site_asset: dict | None = None for asset in assets: if asset["name"] == site_name: site_asset = asset @@ -238,6 +238,20 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) + sensors_to_show = [ + { + "title": "State of charge", + "sensors": [soc_minima_sensor["id"], soc_maxima_sensor["id"], soc_sensor["id"]], + }, + { + "title": "Prices", + "sensor": price_sensor["id"], + }, + ] + await fm_client.update_asset( + asset_id=site_asset["id"], + updates=dict(sensors_to_show=sensors_to_show), + ) return price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor From 5ef6f6431b44ac4edce331fda029eadcb29bba85 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:20:04 +0100 Subject: [PATCH 037/129] fix: type annotation Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index b55a67db..575f57e4 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -124,7 +124,7 @@ async def websocket_handler(request): async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) From 010d584f50542d8b8696e7975160d80ae34ba703 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:24:42 +0100 Subject: [PATCH 038/129] fix: flex-model soc-unit Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 81213c88..432fda1a 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -141,7 +141,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): "site-power-capacity": f"{3 * 25 * 230} VA", }, flex_model={ - "soc-unit": "MWh", + "soc-unit": self.energy_unit, "soc-at-start": soc_at_start, # TODO: use forecast of the SOC instead "soc-min": soc_min, "soc-max": soc_max, From c38951040e185f821c7f92f08109bfb317c57d42 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:42:25 +0100 Subject: [PATCH 039/129] fix: get rid of valid_from_shift Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 8 +------- .../s2/control_types/FRBC/frbc_tunes.py | 5 ----- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 432fda1a..80874739 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -39,7 +39,6 @@ class FRBCSimple(FRBC): _soc_sensor_id: int _rm_discharge_sensor_id: int _schedule_duration: timedelta - _valid_from_shift: timedelta def __init__( self, @@ -52,7 +51,6 @@ def __init__( timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, - valid_from_shift: timedelta = timedelta(days=1), power_unit: str = "kW", energy_unit: str = "kWh", ) -> None: @@ -65,10 +63,6 @@ def __init__( self._soc_minima_sensor_id = soc_minima_sensor_id self._soc_maxima_sensor_id = soc_maxima_sensor_id self._timezone = pytz.timezone(timezone) - - # delay the start of the schedule from the time `valid_from` - # of the FRBC.SystemDescription. - self._valid_from_shift = valid_from_shift self.power_unit = power_unit self.energy_unit = energy_unit @@ -129,7 +123,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): # call schedule start = ( - system_description.valid_from + self._valid_from_shift + system_description.valid_from ) # TODO: localize datetime start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) schedule = await self._fm_client.trigger_and_get_schedule( diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py index 2c647209..21217669 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py @@ -123,7 +123,6 @@ def __init__( schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, fill_level_scale: float = 0.1, - valid_from_shift: timedelta = timedelta(days=1), timers: dict[str, datetime] | None = None, datastore: dict | None = None, **kwargs, @@ -154,10 +153,6 @@ def __init__( self._production_price_sensor_id = production_price_sensor self._timezone = pytz.timezone(timezone) - - # delay the start of the schedule from the time `valid_from` of the FRBC.SystemDescription - self._valid_from_shift = valid_from_shift - self._fill_level_scale = fill_level_scale self._active_recurring_schedule = False From e5ad8a0a9f86bb248dc09161d8bab92e7697ae4a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:45:01 +0100 Subject: [PATCH 040/129] dev: log used SystemDescription Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 80874739..672cda95 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -107,9 +107,11 @@ async def trigger_schedule(self, system_description_id: str | None = None): self._system_description_history[system_description_id] ) else: + # Use last SystemDescription system_description: FRBCSystemDescription = list( self._system_description_history.values() )[-1] + self._logger.debug(f"Using system description: {system_description}") if len(self._storage_status_history) > 0: soc_at_start = list(self._storage_status_history.values())[ From 54dd188138d2b4737610d97afa326ec16b8875b4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:47:07 +0100 Subject: [PATCH 041/129] style: black Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 13 +++++++--- .../s2/script/websockets_server.py | 26 +++++++++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 672cda95..b81a8c53 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -111,6 +111,15 @@ async def trigger_schedule(self, system_description_id: str | None = None): system_description: FRBCSystemDescription = list( self._system_description_history.values() )[-1] + system_descriptions = self._system_description_history.values() + self._logger.error( + list( + [ + system_description.valid_from + for system_description in system_descriptions + ] + ) + ) self._logger.debug(f"Using system description: {system_description}") if len(self._storage_status_history) > 0: @@ -124,9 +133,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): soc_min, soc_max = get_soc_min_max(system_description) # call schedule - start = ( - system_description.valid_from - ) # TODO: localize datetime + start = system_description.valid_from # TODO: localize datetime start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) schedule = await self._fm_client.trigger_and_get_schedule( start=start, diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 575f57e4..b1018adc 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -93,9 +93,14 @@ async def websocket_handler(request): "toy-password", "toy-user@flexmeasures.io", host="server:5000" ) - price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor = await configure_site( - site_name, fm_client - ) + ( + price_sensor, + power_sensor, + soc_sensor, + rm_discharge_sensor, + soc_minima_sensor, + soc_maxima_sensor, + ) = await configure_site(site_name, fm_client) cem = CEM( sensor_id=power_sensor["id"], @@ -241,7 +246,11 @@ async def configure_site( sensors_to_show = [ { "title": "State of charge", - "sensors": [soc_minima_sensor["id"], soc_maxima_sensor["id"], soc_sensor["id"]], + "sensors": [ + soc_minima_sensor["id"], + soc_maxima_sensor["id"], + soc_sensor["id"], + ], }, { "title": "Prices", @@ -252,7 +261,14 @@ async def configure_site( asset_id=site_asset["id"], updates=dict(sensors_to_show=sensors_to_show), ) - return price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor + return ( + price_sensor, + power_sensor, + soc_sensor, + rm_discharge_sensor, + soc_minima_sensor, + soc_maxima_sensor, + ) app = web.Application() From 2819e4f4da0f7661725e2344db981b58f69531d0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 10:55:10 +0100 Subject: [PATCH 042/129] feat: port send_usage_forecast Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 53 +++++++++++++++++-- .../s2/script/websockets_server.py | 16 +++++- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index b81a8c53..849d3a92 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -15,6 +15,7 @@ FRBCFillLevelTargetProfile, FRBCStorageStatus, FRBCSystemDescription, + FRBCUsageForecast, ) except ImportError: raise ImportError( @@ -30,6 +31,7 @@ ) from flexmeasures_client.s2.control_types.translations import ( translate_fill_level_target_profile, + translate_usage_forecast_to_fm, ) @@ -38,7 +40,12 @@ class FRBCSimple(FRBC): _price_sensor_id: int _soc_sensor_id: int _rm_discharge_sensor_id: int + _soc_minima_sensor_id: int + _soc_maxima_sensor_id: int + _usage_forecast_sensor_id: int _schedule_duration: timedelta + _fill_level_scale: int = 1 + _resolution = "15min" def __init__( self, @@ -48,6 +55,7 @@ def __init__( price_sensor_id: int, soc_minima_sensor_id: int, soc_maxima_sensor_id: int, + usage_forecast_sensor_id: int, timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, @@ -62,6 +70,7 @@ def __init__( self._rm_discharge_sensor_id = rm_discharge_sensor_id self._soc_minima_sensor_id = soc_minima_sensor_id self._soc_maxima_sensor_id = soc_maxima_sensor_id + self._usage_forecast_sensor_id = usage_forecast_sensor_id self._timezone = pytz.timezone(timezone) self.power_unit = power_unit self.energy_unit = energy_unit @@ -151,6 +160,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): "soc-minima": {"sensor": self._soc_minima_sensor_id}, "soc-maxima": {"sensor": self._soc_maxima_sensor_id}, "state-of-charge": {"sensor": self._soc_sensor_id}, + "soc-usage": [{"sensor": self._usage_forecast_sensor_id}], }, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, @@ -177,15 +187,14 @@ async def send_fill_level_target_profile( """ # if not self._is_timer_due("fill_level_target_profile"): # return - RESOLUTION = "15min" soc_minima, soc_maxima = translate_fill_level_target_profile( fill_level_target_profile=fill_level_target_profile, - resolution=RESOLUTION, - fill_level_scale=1, + resolution=self._resolution, + fill_level_scale=self._fill_level_scale, ) - duration = str(pd.Timedelta(RESOLUTION) * len(soc_maxima)) + duration = str(pd.Timedelta(self._resolution) * len(soc_maxima)) # POST SOC Minima measurements to FlexMeasures await self._fm_client.post_sensor_data( @@ -204,3 +213,39 @@ async def send_fill_level_target_profile( unit=self.energy_unit, duration=duration, ) + + async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): + """ + Send FRBC.UsageForecast to FlexMeasures. + + Args: + usage_forecast (FRBCUsageForecast): The usage forecast to be translated and sent. + """ + # if not self._is_timer_due("usage_forecast"): + # return + + start_time = usage_forecast.start_time + + # flooring to previous 15min tick + start_time = start_time.replace( + minute=(start_time.minute // 15) * 15, second=0, microsecond=0 + ) + + usage_forecast = translate_usage_forecast_to_fm( + usage_forecast, + self._resolution, + strategy="mean", + fill_level_scale=self._fill_level_scale, + ) + + # Scale usage forecast e.g. [0, 100] %/s -> [0, 100] %/(15 min) + scale = timedelta(minutes=15) / timedelta(seconds=1) + scaled_usage_forecast = usage_forecast * scale + + await self._fm_client.post_sensor_data( + sensor_id=self._usage_forecast_sensor_id, + start=start_time, + values=scaled_usage_forecast.tolist(), + unit=self.power_unit, # e.g. [0, 100] MW/(15 min) + duration=str(pd.Timedelta(self._resolution) * len(usage_forecast)), + ) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index b1018adc..19c104cb 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -100,6 +100,7 @@ async def websocket_handler(request): rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor, + usage_forecast_sensor, ) = await configure_site(site_name, fm_client) cem = CEM( @@ -114,6 +115,7 @@ async def websocket_handler(request): rm_discharge_sensor_id=rm_discharge_sensor["id"], soc_minima_sensor_id=soc_minima_sensor["id"], soc_maxima_sensor_id=soc_maxima_sensor["id"], + usage_forecast_sensor_id=usage_forecast_sensor["id"], ) cem.register_control_type(frbc) @@ -129,7 +131,7 @@ async def websocket_handler(request): async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) @@ -162,6 +164,7 @@ async def configure_site( rm_discharge_sensor = None soc_minima_sensor = None soc_maxima_sensor = None + usage_forecast_sensor = None for sensor in sensors: if sensor["name"] == "price": price_sensor = sensor @@ -175,6 +178,8 @@ async def configure_site( soc_minima_sensor = sensor elif sensor["name"] == "soc-maxima": soc_maxima_sensor = sensor + elif sensor["name"] == "usage-forecast": + usage_forecast_sensor = sensor if price_sensor is None: price_sensor = await fm_client.add_sensor( @@ -243,6 +248,14 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) + if usage_forecast_sensor is None: + usage_forecast_sensor = await fm_client.add_sensor( + name="usage-forecast", + event_resolution="PT15M", + unit="kW", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) sensors_to_show = [ { "title": "State of charge", @@ -268,6 +281,7 @@ async def configure_site( rm_discharge_sensor, soc_minima_sensor, soc_maxima_sensor, + usage_forecast_sensor, ) From 6cf71ad21d17c4f02f169c0c80772a24ccf5ecd5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 11:04:42 +0100 Subject: [PATCH 043/129] feat: relax constraints Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 849d3a92..4a1b1f39 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -151,6 +151,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): "production-price": {"sensor": self._price_sensor_id}, "consumption-price": {"sensor": self._price_sensor_id}, "site-power-capacity": f"{3 * 25 * 230} VA", + "relax-constraints": True, }, flex_model={ "soc-unit": self.energy_unit, From b78fbc8cd956665637a8e063c544f0d1af47a0a3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 14:11:53 +0100 Subject: [PATCH 044/129] feat: we already remove scheduled power values that are not a change with respect to the previous value, but here we also remove instructions that are not a change to the previous instructions; because sometimes different scheduled power values can map to the same instruction Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/utils.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/utils.py b/src/flexmeasures_client/s2/control_types/FRBC/utils.py index 04ce1056..ebb1319a 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/utils.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/utils.py @@ -161,6 +161,7 @@ def fm_schedule_to_instructions( deltaT = timedelta(minutes=15) / timedelta(hours=1) + previous_instruction = None for timestamp, row in schedule.iterrows(): power = row["schedule"] usage = row.get("usage_forecast", 0) @@ -221,9 +222,21 @@ def fm_schedule_to_instructions( execution_time=timestamp, abnormal_condition=False, ) + if previous_instruction and all( + getattr(previous_instruction, attr) == getattr(instruction, attr) + for attr in ( + "actuator_id", + "operation_mode", + "operation_mode_factor", + "abnormal_condition", + ) + ): + logger.info("Instruction removed, no changes to previous instruction") + continue logger.info( f"Instruction created: at {timestamp} set {actuator.diagnostic_label if isinstance(actuator.diagnostic_label, str) else actuator} to {best_operation_mode.diagnostic_label if isinstance(best_operation_mode.diagnostic_label, str) else best_operation_mode} with factor {operation_mode_factor}" ) + previous_instruction = instruction instructions.append(instruction) # Update fill level From baac3bb3ae922502e0b590e823029a05b759cdb1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 14:14:17 +0100 Subject: [PATCH 045/129] dev: debug log JSON instructions Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/utils.py | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/utils.py b/src/flexmeasures_client/s2/control_types/FRBC/utils.py index ebb1319a..46cc7d4e 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/utils.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/utils.py @@ -1,4 +1,6 @@ +import json import logging +import uuid from datetime import timedelta from math import isclose from typing import List @@ -9,6 +11,7 @@ try: from s2python.common import NumberRange from s2python.frbc import ( + FRBCActuatorDescription, FRBCInstruction, FRBCLeakageBehaviour, FRBCOperationMode, @@ -155,7 +158,12 @@ def fm_schedule_to_instructions( f"{len(system_description.actuators)} were provided" ) - operation_modes: list[FRBCOperationMode] = actuator.operation_modes + actuators: dict[uuid.UUID, FRBCActuatorDescription] = { + a.id: a for a in system_description.actuators + } + operation_modes: dict[uuid.UUID, FRBCOperationMode] = { + om.id: om for om in actuator.operation_modes + } fill_level = initial_fill_level @@ -170,7 +178,7 @@ def fm_schedule_to_instructions( # Convert from power to fill rate results = [ (om, *power_to_fill_rate_with_metrics(om, power, fill_level)) - for om in operation_modes + for om in operation_modes.values() ] # Step 1: minimize fill-level penalty (primary) @@ -238,6 +246,18 @@ def fm_schedule_to_instructions( ) previous_instruction = instruction instructions.append(instruction) + logger.debug( + "Instructions JSON: %s", + json.dumps( + [ + serialize_instruction( + instr, actuators=actuators, operation_modes=operation_modes + ) + for instr in instructions + ], + indent=2, + ), + ) # Update fill level fill_level = compute_next_fill_level( @@ -394,3 +414,29 @@ def explain_choice( lines.append(f"{label} (element={element_label}): rejected due to {reason}") return "; ".join(lines) + + +def serialize_instruction( + instr: FRBCInstruction, + actuators: dict[uuid.UUID, FRBCActuatorDescription], + operation_modes: dict[uuid.UUID, FRBCOperationMode], +): + """Create dict of instructions suitable for logging.""" + actuator = ( + getattr(actuators[instr.actuator_id], "diagnostic_label", None) + or instr.actuator_id + ) + operation_mode = ( + getattr(operation_modes[instr.operation_mode], "diagnostic_label", None) + or instr.operation_mode + ) + return { + "message_type": instr.message_type, + "message_id": str(instr.message_id), + "instruction_id": str(instr.id), + "actuator": str(actuator), + "operation_mode": str(operation_mode), + "operation_mode_factor": instr.operation_mode_factor, + "execution_time": instr.execution_time.isoformat(), + "abnormal_condition": instr.abnormal_condition, + } From 0577d534c5cb5e34faa5863676a7185bedf6ad1f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 14:18:59 +0100 Subject: [PATCH 046/129] dev: speed up polling for simulations Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 19c104cb..0660922e 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -90,7 +90,10 @@ async def websocket_handler(request): site_name = "My CEM" fm_client = FlexMeasuresClient( - "toy-password", "toy-user@flexmeasures.io", host="server:5000" + host="server:5000", + email="toy-user@flexmeasures.io", + password="toy-password", + polling_interval=0.5, ) ( From b99b39b0508b3ae65207a930534ce85873ba9304 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:03:14 +0100 Subject: [PATCH 047/129] docs: add instruction to create an admin user Signed-off-by: F.N. Claessen --- docs/CEM.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/CEM.rst b/docs/CEM.rst index 98fe41d5..5a604d45 100644 --- a/docs/CEM.rst +++ b/docs/CEM.rst @@ -40,6 +40,14 @@ To test, run the included example RM: python3 flexmeasures_client/s2/script/websockets_client.py +For full access via the UI, create an admin user for the Docker Toy Account (here, we assume it has ID 1): + +.. code-block:: bash + + docker exec -it flexmeasures-server-1 bash + flexmeasures show accounts + flexmeasures add user --roles admin --account 1 --email --username + Disclaimer ========== From 7ce4c35bbcc6edb76c8d2f9f8f1425b130a5e52b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:10:48 +0100 Subject: [PATCH 048/129] refactor: rename variable Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 4a1b1f39..d85ec637 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -98,11 +98,11 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): + (fill_rate.end_of_range - fill_rate.start_of_range) * factor ) - dt = status.transition_timestamp or self.now() + start = status.transition_timestamp or self.now() await self._fm_client.post_sensor_data( self._power_sensor_id, - start=dt, + start=start, values=[-power], unit=self.power_unit, duration=timedelta(minutes=15), From 806dae0cf51e041923ddf676656da37236d39721 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:23:19 +0100 Subject: [PATCH 049/129] style: isort Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index d85ec637..8b7604fb 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -5,9 +5,8 @@ from datetime import datetime, timedelta -import pytz - import pandas as pd +import pytz try: from s2python.frbc import ( From 5be4b64ac9f55a1fa0ef500cca5f1d641d9b6e82 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:27:44 +0100 Subject: [PATCH 050/129] fix: mypy Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 46bc7f99..4abebde7 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -225,7 +225,7 @@ def update_control_type(self, control_type: ControlType): """ self._control_type = control_type - async def get_message(self) -> str: + async def get_message(self) -> tuple[str, asyncio.Future]: """Call this function to get the messages to be sent to the RM Returns: From abb68e062124e36c5070fe7af9bc9351538e1945 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:33:54 +0100 Subject: [PATCH 051/129] chore: resolve implicit todo Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 4abebde7..ace85a71 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -240,10 +240,7 @@ async def get_message(self) -> tuple[str, asyncio.Future]: ) message, fut = item - - # Pending for pydantic V2 to implement model.model_dump(mode="json") in - # PR #1409 (https://github.com/pydantic/pydantic/issues/1409) - message = json.loads(message.json()) + message = message.model_dump(mode="json") return message, fut From 59108c0dec5223bad98988778d79b55cfdbf20d0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:35:29 +0100 Subject: [PATCH 052/129] fix: obvious typo Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py index 21217669..58428840 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py @@ -230,7 +230,7 @@ async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): leakage_behaviour_to_storage_efficiency( message=leakage, resolution=timedelta(minutes=15), - fill_level_scale=self._fill_level_scalefill_level_scale, + fill_level_scale=self._fill_level_scale, ) ], unit=PERCENTAGE, From 92fd551132d302646552311898d00e56a72833c0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:47:14 +0100 Subject: [PATCH 053/129] fix: debug log instead of error log Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 8b7604fb..76d314ef 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -120,7 +120,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): self._system_description_history.values() )[-1] system_descriptions = self._system_description_history.values() - self._logger.error( + self._logger.debug( list( [ system_description.valid_from From 1b8d4c96e1e86817fd1a15703a9dd0a89970dbb9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 27 Feb 2026 16:52:13 +0100 Subject: [PATCH 054/129] feat: port send_leakage_behaviour Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 28 +++++++++++++++++++ .../s2/script/websockets_server.py | 16 ++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 76d314ef..c8d70631 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -12,6 +12,7 @@ from s2python.frbc import ( FRBCActuatorStatus, FRBCFillLevelTargetProfile, + FRBCLeakageBehaviour, FRBCStorageStatus, FRBCSystemDescription, FRBCUsageForecast, @@ -31,6 +32,7 @@ from flexmeasures_client.s2.control_types.translations import ( translate_fill_level_target_profile, translate_usage_forecast_to_fm, + leakage_behaviour_to_storage_efficiency, ) @@ -42,6 +44,7 @@ class FRBCSimple(FRBC): _soc_minima_sensor_id: int _soc_maxima_sensor_id: int _usage_forecast_sensor_id: int + _leakage_behaviour_sensor_id: int _schedule_duration: timedelta _fill_level_scale: int = 1 _resolution = "15min" @@ -55,6 +58,7 @@ def __init__( soc_minima_sensor_id: int, soc_maxima_sensor_id: int, usage_forecast_sensor_id: int, + leakage_behaviour_sensor_id: int, timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, @@ -70,6 +74,7 @@ def __init__( self._soc_minima_sensor_id = soc_minima_sensor_id self._soc_maxima_sensor_id = soc_maxima_sensor_id self._usage_forecast_sensor_id = usage_forecast_sensor_id + self._leakage_behaviour_sensor_id = leakage_behaviour_sensor_id self._timezone = pytz.timezone(timezone) self.power_unit = power_unit self.energy_unit = energy_unit @@ -161,6 +166,7 @@ async def trigger_schedule(self, system_description_id: str | None = None): "soc-maxima": {"sensor": self._soc_maxima_sensor_id}, "state-of-charge": {"sensor": self._soc_sensor_id}, "soc-usage": [{"sensor": self._usage_forecast_sensor_id}], + "storage-efficiency": {"sensor": self._leakage_behaviour_sensor_id}, }, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, @@ -249,3 +255,25 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): unit=self.power_unit, # e.g. [0, 100] MW/(15 min) duration=str(pd.Timedelta(self._resolution) * len(usage_forecast)), ) + + async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): + # if not self._is_timer_due("leakage_behaviour"): + # return + + start = leakage.valid_from or self.now() + start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) + + storage_efficiency = leakage_behaviour_to_storage_efficiency( + message=leakage, + resolution=timedelta(minutes=15), + fill_level_scale=self._fill_level_scale, + ) + self._logger.debug(storage_efficiency) + + await self._fm_client.post_sensor_data( + self._leakage_behaviour_sensor_id, + start=start, + values=[storage_efficiency], + unit="%", + duration=timedelta(hours=48), + ) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 0660922e..6ef67321 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -104,6 +104,7 @@ async def websocket_handler(request): soc_minima_sensor, soc_maxima_sensor, usage_forecast_sensor, + leakage_behaviour_sensor_id, ) = await configure_site(site_name, fm_client) cem = CEM( @@ -119,6 +120,7 @@ async def websocket_handler(request): soc_minima_sensor_id=soc_minima_sensor["id"], soc_maxima_sensor_id=soc_maxima_sensor["id"], usage_forecast_sensor_id=usage_forecast_sensor["id"], + leakage_behaviour_sensor_id=leakage_behaviour_sensor_id["id"], ) cem.register_control_type(frbc) @@ -134,7 +136,7 @@ async def websocket_handler(request): async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) @@ -168,6 +170,7 @@ async def configure_site( soc_minima_sensor = None soc_maxima_sensor = None usage_forecast_sensor = None + leakage_behaviour_sensor = None for sensor in sensors: if sensor["name"] == "price": price_sensor = sensor @@ -183,6 +186,8 @@ async def configure_site( soc_maxima_sensor = sensor elif sensor["name"] == "usage-forecast": usage_forecast_sensor = sensor + elif sensor["name"] == "leakage-behaviour": + leakage_behaviour_sensor = sensor if price_sensor is None: price_sensor = await fm_client.add_sensor( @@ -259,6 +264,14 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) + if leakage_behaviour_sensor is None: + leakage_behaviour_sensor = await fm_client.add_sensor( + name="leakage-behaviour", + event_resolution="PT15M", + unit="%", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) sensors_to_show = [ { "title": "State of charge", @@ -285,6 +298,7 @@ async def configure_site( soc_minima_sensor, soc_maxima_sensor, usage_forecast_sensor, + leakage_behaviour_sensor, ) From fe02d3313db3a94ff217f104b7c02bb1b2de24d2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Mar 2026 17:28:52 +0100 Subject: [PATCH 055/129] feat: CEM supports setting simulation time, by wrapping the S2 message together with metadata in an envelope, where the metadata contains a dt Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index ace85a71..6b1ea8fd 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -185,7 +185,19 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): if isinstance(message, str): message = json.loads(message) - self._logger.debug(f"Received: {message}") + # Detect wrapper + if isinstance(message, dict) and "message" in message and "metadata" in message: + metadata = message["metadata"] + message = message["message"] + self._logger.debug(f"Received wrapped message") + self._logger.debug(f"Received message: {message}") + self._logger.debug(f"Received metadata: {metadata}") + if "dt" in metadata: + for control_type in self._control_types_handlers.values(): + control_type.now = lambda: metadata["dt"] + self.now = lambda: metadata["dt"] + else: + self._logger.debug(f"Received: {message}") # try to handle the message with the control_type handle if ( From fd91a624f538d76568fab7123ed0ef7cc88bfd84 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Mar 2026 19:48:47 +0100 Subject: [PATCH 056/129] style: isort, flake8 Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 2 +- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 6b1ea8fd..e94394ff 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -189,7 +189,7 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): if isinstance(message, dict) and "message" in message and "metadata" in message: metadata = message["metadata"] message = message["message"] - self._logger.debug(f"Received wrapped message") + self._logger.debug("Received wrapped message") self._logger.debug(f"Received message: {message}") self._logger.debug(f"Received metadata: {metadata}") if "dt" in metadata: diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index c8d70631..1e5d3e64 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -30,9 +30,9 @@ get_soc_min_max, ) from flexmeasures_client.s2.control_types.translations import ( + leakage_behaviour_to_storage_efficiency, translate_fill_level_target_profile, translate_usage_forecast_to_fm, - leakage_behaviour_to_storage_efficiency, ) From e8e60061c3fd422dbf473482a8f841eaa61831aa Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Mar 2026 19:55:29 +0100 Subject: [PATCH 057/129] feat: record data in FlexMeasures as if it was recorded at simulation time Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 1e5d3e64..309fd441 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -86,6 +86,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): await self._fm_client.post_sensor_data( self._soc_sensor_id, start=self.now(), + prior=self.now(), values=[status.present_fill_level], unit=self.energy_unit, duration=timedelta(minutes=1), @@ -107,6 +108,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): await self._fm_client.post_sensor_data( self._power_sensor_id, start=start, + prior=self.now(), values=[-power], unit=self.power_unit, duration=timedelta(minutes=15), @@ -206,6 +208,7 @@ async def send_fill_level_target_profile( await self._fm_client.post_sensor_data( sensor_id=self._soc_minima_sensor_id, start=fill_level_target_profile.start_time, + prior=self.now(), values=soc_minima.tolist(), unit=self.energy_unit, duration=duration, @@ -215,6 +218,7 @@ async def send_fill_level_target_profile( await self._fm_client.post_sensor_data( sensor_id=self._soc_maxima_sensor_id, start=fill_level_target_profile.start_time, + prior=self.now(), values=soc_maxima.tolist(), unit=self.energy_unit, duration=duration, @@ -251,6 +255,7 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): await self._fm_client.post_sensor_data( sensor_id=self._usage_forecast_sensor_id, start=start_time, + prior=self.now(), values=scaled_usage_forecast.tolist(), unit=self.power_unit, # e.g. [0, 100] MW/(15 min) duration=str(pd.Timedelta(self._resolution) * len(usage_forecast)), @@ -273,6 +278,7 @@ async def send_leakage_behaviour(self, leakage: FRBCLeakageBehaviour): await self._fm_client.post_sensor_data( self._leakage_behaviour_sensor_id, start=start, + prior=self.now(), values=[storage_efficiency], unit="%", duration=timedelta(hours=48), From 14bf5b8802eaf412329bfee73bfba2e799950724 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Mar 2026 20:02:45 +0100 Subject: [PATCH 058/129] style: silence mypy on overwriting a method with a lambda function Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index e94394ff..d0594783 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -194,8 +194,8 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): self._logger.debug(f"Received metadata: {metadata}") if "dt" in metadata: for control_type in self._control_types_handlers.values(): - control_type.now = lambda: metadata["dt"] - self.now = lambda: metadata["dt"] + control_type.now = lambda: metadata["dt"] # type: ignore + self.now = lambda: metadata["dt"] # type: ignore else: self._logger.debug(f"Received: {message}") From 7e6b29ae50bb6d110c3b8c1308397b1b8c026f27 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Mar 2026 18:06:18 +0100 Subject: [PATCH 059/129] feat: force new scheduling job creation when using a prior Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 395fa109..9a25b85e 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1228,6 +1228,7 @@ async def trigger_schedule( if prior is not None: message["prior"] = pd.Timestamp(prior).isoformat() + message["force_new_job_creation"] = True if scheduler is not None: if asset_id is None: raise ValueError( From a89a41e95784652773ccefe7a92822be241b0f96 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Mar 2026 18:07:23 +0100 Subject: [PATCH 060/129] fix: upgrade timely-beliefs to fix scheduler bug with resampling from non-instantaneous to instantaneous Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index e562a60c..ad076c39 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -13,9 +13,16 @@ services: command: - | pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt + pip install timely-beliefs -U --break-system-packages flexmeasures db upgrade # toy account step removed gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:application + worker: + command: + - | + pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt + pip install timely-beliefs -U --break-system-packages + flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling cem: build: context: . From 31bddecbe3801b4bc2f844b6c79ca292ba0b9695 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 10:01:15 +0100 Subject: [PATCH 061/129] fix: pass prior to trigger_and_get_schedule Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 309fd441..27a45347 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -149,9 +149,9 @@ async def trigger_schedule(self, system_description_id: str | None = None): # call schedule start = system_description.valid_from # TODO: localize datetime - start = start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0) schedule = await self._fm_client.trigger_and_get_schedule( - start=start, + start=start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0), + prior=start, sensor_id=self._power_sensor_id, flex_context={ "production-price": {"sensor": self._price_sensor_id}, From 906a7f57df68558140b586ac96ff1083d262e7a9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 10:04:40 +0100 Subject: [PATCH 062/129] fix: start schedule from the time of the most recent storage status Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 27a45347..f15d390c 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -83,15 +83,16 @@ def now(self): return self._timezone.localize(datetime.now()) async def send_storage_status(self, status: FRBCStorageStatus): + now = self.now() await self._fm_client.post_sensor_data( self._soc_sensor_id, - start=self.now(), - prior=self.now(), + start=now, + prior=now, values=[status.present_fill_level], unit=self.energy_unit, duration=timedelta(minutes=1), ) - await self.trigger_schedule() + await self.trigger_schedule(now) async def send_actuator_status(self, status: FRBCActuatorStatus): factor = status.operation_mode_factor @@ -114,7 +115,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): duration=timedelta(minutes=15), ) - async def trigger_schedule(self, system_description_id: str | None = None): + async def trigger_schedule(self, start: datetime, system_description_id: str | None = None): """Translates S2 System Description into FM API calls""" if system_description_id: @@ -148,7 +149,8 @@ async def trigger_schedule(self, system_description_id: str | None = None): soc_min, soc_max = get_soc_min_max(system_description) # call schedule - start = system_description.valid_from # TODO: localize datetime + if isinstance(start, str): + start = pd.Timestamp(start) schedule = await self._fm_client.trigger_and_get_schedule( start=start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0), prior=start, From f61fd60783bd8f60d7763be5070daadeb5150dc0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 10:17:27 +0100 Subject: [PATCH 063/129] style: black Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index f15d390c..3907d587 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -115,7 +115,9 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): duration=timedelta(minutes=15), ) - async def trigger_schedule(self, start: datetime, system_description_id: str | None = None): + async def trigger_schedule( + self, start: datetime, system_description_id: str | None = None + ): """Translates S2 System Description into FM API calls""" if system_description_id: @@ -152,7 +154,9 @@ async def trigger_schedule(self, start: datetime, system_description_id: str | N if isinstance(start, str): start = pd.Timestamp(start) schedule = await self._fm_client.trigger_and_get_schedule( - start=start.replace(minute=(start.minute // 15) * 15, second=0, microsecond=0), + start=start.replace( + minute=(start.minute // 15) * 15, second=0, microsecond=0 + ), prior=start, sensor_id=self._power_sensor_id, flex_context={ From 2d5b0fe6af316845ed01fb9185a5234b97aa36aa Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 10:59:25 +0100 Subject: [PATCH 064/129] fix: still make sure to run `flexmeasures add toy-account` once Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index ad076c39..93b0e749 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -15,7 +15,9 @@ services: pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages flexmeasures db upgrade - # toy account step removed + if ! flexmeasures show accounts | grep -q "Docker Toy Account"; then + flexmeasures add toy-account --name 'Docker Toy Account' + fi gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:application worker: command: From eda6b2854fc8fde751b0139b1daf44ea2667bbe5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 14:39:27 +0100 Subject: [PATCH 065/129] feat: add ability to customize fill_level_scale Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 3907d587..cd6f0fec 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -46,7 +46,7 @@ class FRBCSimple(FRBC): _usage_forecast_sensor_id: int _leakage_behaviour_sensor_id: int _schedule_duration: timedelta - _fill_level_scale: int = 1 + _fill_level_scale: float _resolution = "15min" def __init__( @@ -62,6 +62,7 @@ def __init__( timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, + fill_level_scale: float = 1, power_unit: str = "kW", energy_unit: str = "kWh", ) -> None: @@ -76,6 +77,7 @@ def __init__( self._usage_forecast_sensor_id = usage_forecast_sensor_id self._leakage_behaviour_sensor_id = leakage_behaviour_sensor_id self._timezone = pytz.timezone(timezone) + self._fill_level_scale = fill_level_scale self.power_unit = power_unit self.energy_unit = energy_unit @@ -88,7 +90,7 @@ async def send_storage_status(self, status: FRBCStorageStatus): self._soc_sensor_id, start=now, prior=now, - values=[status.present_fill_level], + values=[status.present_fill_level * self._fill_level_scale], unit=self.energy_unit, duration=timedelta(minutes=1), ) @@ -102,7 +104,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): power = ( fill_rate.start_of_range + (fill_rate.end_of_range - fill_rate.start_of_range) * factor - ) + ) * self._fill_level_scale start = status.transition_timestamp or self.now() @@ -141,14 +143,15 @@ async def trigger_schedule( self._logger.debug(f"Using system description: {system_description}") if len(self._storage_status_history) > 0: - soc_at_start = list(self._storage_status_history.values())[ - -1 - ].present_fill_level + soc_at_start = ( + list(self._storage_status_history.values())[-1].present_fill_level + * self._fill_level_scale + ) else: print("Can't trigger schedule without knowing the status of the storage...") return - soc_min, soc_max = get_soc_min_max(system_description) + soc_min, soc_max = get_soc_min_max(system_description, self._fill_level_scale) # call schedule if isinstance(start, str): @@ -183,7 +186,9 @@ async def trigger_schedule( # translate FlexMeasures schedule into instructions. SOC -> Power -> PowerFactor instructions = fm_schedule_to_instructions( - schedule, system_description, initial_fill_level=soc_at_start + schedule, + system_description, + initial_fill_level=soc_at_start / self._fill_level_scale, ) # put instructions to sending queue From 1abdd718f2b0f848f679d068e9261f6d53393c14 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 14:45:18 +0100 Subject: [PATCH 066/129] dev: remove todo (soc-at-start is now actually coming from the latest storage status) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index cd6f0fec..2259d7b0 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -170,7 +170,7 @@ async def trigger_schedule( }, flex_model={ "soc-unit": self.energy_unit, - "soc-at-start": soc_at_start, # TODO: use forecast of the SOC instead + "soc-at-start": soc_at_start, "soc-min": soc_min, "soc-max": soc_max, "soc-minima": {"sensor": self._soc_minima_sensor_id}, From 9f20ab4379d0c06bdafaee70f3067d67752a6657 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 15:16:26 +0100 Subject: [PATCH 067/129] feat: derive consumption-capacity and production-capacity from operation mode elements Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 2259d7b0..47576f6d 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -151,6 +151,27 @@ async def trigger_schedule( print("Can't trigger schedule without knowing the status of the storage...") return + # Assume a single actuator + actuator = system_description.actuators[0] + + # Derive the overall power range + charging_capacity = None + discharging_capacity = None + for operation_mode in actuator.operation_modes: + for element in operation_mode.elements: + for power_range in element.power_ranges: + # todo: distinguish power range per commodity + p_min = power_range.end_of_range + if discharging_capacity is None: + discharging_capacity = p_min + else: + discharging_capacity = min(discharging_capacity, p_min) + p_max = power_range.start_of_range + if charging_capacity is None: + charging_capacity = p_max + else: + charging_capacity = max(charging_capacity, p_max) + soc_min, soc_max = get_soc_min_max(system_description, self._fill_level_scale) # call schedule @@ -178,6 +199,8 @@ async def trigger_schedule( "state-of-charge": {"sensor": self._soc_sensor_id}, "soc-usage": [{"sensor": self._usage_forecast_sensor_id}], "storage-efficiency": {"sensor": self._leakage_behaviour_sensor_id}, + "consumption-capacity": f"{charging_capacity} {self.power_unit}", + "production-capacity": f"{discharging_capacity} {self.power_unit}", }, duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, From ea68aa0360f00e4779e67fa6d4b060683a8c68ba Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 15:19:42 +0100 Subject: [PATCH 068/129] docs: update docstring Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 47576f6d..9429f947 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -120,7 +120,9 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): async def trigger_schedule( self, start: datetime, system_description_id: str | None = None ): - """Translates S2 System Description into FM API calls""" + """ + Ask FlexMeasures for a new schedule and create FRBC.Instructions to send back to the ResourceManager + """ if system_description_id: system_description: FRBCSystemDescription = ( From 605980ac730a5b194e8840b87f661ac80e7096a6 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 15:36:40 +0100 Subject: [PATCH 069/129] feat: move to J and W as default energy unit and power unit, respectively Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 9429f947..98b98f57 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -63,8 +63,8 @@ def __init__( schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, fill_level_scale: float = 1, - power_unit: str = "kW", - energy_unit: str = "kWh", + power_unit: str = "W", + energy_unit: str = "J", ) -> None: super().__init__(max_size) self._power_sensor_id = power_sensor_id @@ -176,6 +176,16 @@ async def trigger_schedule( soc_min, soc_max = get_soc_min_max(system_description, self._fill_level_scale) + # Support for J energy unit (FM server only accepts kWh and MWh) + if self.energy_unit == "J": + f = 3.6 * 10 ** 6 + energy_unit = "kWh" + soc_at_start *= f + soc_min *= f + soc_max *= f + else: + energy_unit = self.energy_unit + # call schedule if isinstance(start, str): start = pd.Timestamp(start) @@ -192,7 +202,7 @@ async def trigger_schedule( "relax-constraints": True, }, flex_model={ - "soc-unit": self.energy_unit, + "soc-unit": energy_unit, "soc-at-start": soc_at_start, "soc-min": soc_min, "soc-max": soc_max, From c2bab5d1a18b320ac0c51da5926bfcf4d413f646 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 16:22:54 +0100 Subject: [PATCH 070/129] fix: wrong conversion Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 98b98f57..99daa69b 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -180,9 +180,9 @@ async def trigger_schedule( if self.energy_unit == "J": f = 3.6 * 10 ** 6 energy_unit = "kWh" - soc_at_start *= f - soc_min *= f - soc_max *= f + soc_at_start /= f + soc_min /= f + soc_max /= f else: energy_unit = self.energy_unit From 00f363ab24551631a2080de6998207ba630ba00b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 16:30:19 +0100 Subject: [PATCH 071/129] docs: clarify inline note Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 99daa69b..5e3d6b2c 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -176,7 +176,7 @@ async def trigger_schedule( soc_min, soc_max = get_soc_min_max(system_description, self._fill_level_scale) - # Support for J energy unit (FM server only accepts kWh and MWh) + # Support for J energy unit (FM server scheduling trigger endpoint only accepts kWh and MWh) if self.energy_unit == "J": f = 3.6 * 10 ** 6 energy_unit = "kWh" From 80b2f6154ab128a7358877e3d44d1664d09e3ee5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:01:51 +0100 Subject: [PATCH 072/129] refactor: prepare for more params Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 9a25b85e..26a579d1 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -607,12 +607,9 @@ async def get_schedule( 'unit': 'MW' } """ + params = {} if duration is not None: - params = { - "duration": pd.Timedelta(duration).isoformat(), # for example: PT1H - } - else: - params = {} + params["duration"] = pd.Timedelta(duration).isoformat(), # for example: PT1H schedule, status = await self.request( uri=f"sensors/{sensor_id}/schedules/{schedule_id}", method="GET", From 2cc3b750a73fe11dca24e4c1a188d1c9afc22251 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:04:39 +0100 Subject: [PATCH 073/129] feat: support getting a schedule in a given unit Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 26a579d1..58260ca3 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -596,6 +596,7 @@ async def get_schedule( sensor_id: int, schedule_id: str, duration: str | timedelta | None = None, + unit: str | None = None, ) -> dict: """Get schedule with given ID. @@ -609,7 +610,11 @@ async def get_schedule( """ params = {} if duration is not None: - params["duration"] = pd.Timedelta(duration).isoformat(), # for example: PT1H + params["duration"] = ( + pd.Timedelta(duration).isoformat(), + ) # for example: PT1H + if unit is not None: + params["unit"] = unit schedule, status = await self.request( uri=f"sensors/{sensor_id}/schedules/{schedule_id}", method="GET", @@ -828,6 +833,7 @@ async def trigger_and_get_schedule( asset_id: int | None = None, prior: datetime | None = None, scheduler: str | None = None, + unit: str | None = None, ) -> dict | list[dict]: """Trigger a schedule and then fetch it. @@ -863,7 +869,10 @@ async def trigger_and_get_schedule( if sensor_id is not None: # Get the schedule for a single device return await self.get_schedule( - sensor_id=sensor_id, schedule_id=schedule_id, duration=duration + sensor_id=sensor_id, + schedule_id=schedule_id, + duration=duration, + unit=unit, ) elif flex_model is None: # If there is no flex-model referencing power sensors, no power schedules are retrieved From 3a547b1c5046aa60f5cc5397221dd224cd57b1bb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:05:28 +0100 Subject: [PATCH 074/129] feat: require minimum version for getting a schedule in a given unit Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 58260ca3..f2d99bcb 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -614,6 +614,12 @@ async def get_schedule( pd.Timedelta(duration).isoformat(), ) # for example: PT1H if unit is not None: + await self.ensure_server_version() + if Version(self.server_version) < Version("0.31.0"): + self.logger.warning( + "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " + "This parameter will be ignored." + ) params["unit"] = unit schedule, status = await self.request( uri=f"sensors/{sensor_id}/schedules/{schedule_id}", From d29e64c9d1291d72630cf85e1aa19ad69b60a7da Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:06:00 +0100 Subject: [PATCH 075/129] fix: get schedule in the assumed power unit Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 5e3d6b2c..57c46595 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -217,6 +217,7 @@ async def trigger_schedule( duration=self._schedule_duration, # next 12 hours # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, # this needs changes on the client + unit=self.power_unit, ) # translate FlexMeasures schedule into instructions. SOC -> Power -> PowerFactor From fca2c5ba575fb9cc69803986a5efe561213255ce Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:13:27 +0100 Subject: [PATCH 076/129] feat: note the current FM server version Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index f2d99bcb..77604dc2 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -618,7 +618,7 @@ async def get_schedule( if Version(self.server_version) < Version("0.31.0"): self.logger.warning( "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " - "This parameter will be ignored." + f"This parameter will be ignored for server version {self.server_version}." ) params["unit"] = unit schedule, status = await self.request( @@ -754,7 +754,7 @@ async def get_assets( if Version(self.server_version) < Version("0.31.0"): self.logger.warning( "get_assets(): The 'root', 'depth' and 'fields' parameters require FlexMeasures server version 0.31.0 or above. " - "These parameters will be ignored." + f"These parameters will be ignored for server version {self.server_version}." ) if root and isinstance(root, int): uri += f"&root={root}" @@ -1127,7 +1127,7 @@ async def update_asset(self, asset_id: int, updates: dict) -> dict: if Version(self.server_version) < Version("0.31.0"): self.logger.warning( "update_asset(): The 'aggregate-power' flex-context field requires FlexMeasures server version 0.31.0 or above. " - "The 'aggregate-power' field will be ignored by the server." + f"The 'aggregate-power' field will be ignored by the server, which is at version {self.server_version}." ) updates["flex_context"] = json.dumps(updates["flex_context"]) if "flex_model" in updates: From fb6d9478514d1077196ca5948e7c5f9cbee863f2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:17:20 +0100 Subject: [PATCH 077/129] fix: actually requires v0.32.0 Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 77604dc2..0ee72861 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -615,7 +615,7 @@ async def get_schedule( ) # for example: PT1H if unit is not None: await self.ensure_server_version() - if Version(self.server_version) < Version("0.31.0"): + if Version(self.server_version) < Version("0.32.0"): self.logger.warning( "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " f"This parameter will be ignored for server version {self.server_version}." From 7d4c0c0916d975e01d3b319c98f9f1f13194a7f0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 20:18:43 +0100 Subject: [PATCH 078/129] docs: clarify that the server ignores the parameter, not the client Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 0ee72861..eca21e8c 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -618,7 +618,7 @@ async def get_schedule( if Version(self.server_version) < Version("0.32.0"): self.logger.warning( "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " - f"This parameter will be ignored for server version {self.server_version}." + f"This parameter will be ignored by the server, which is at version {self.server_version}." ) params["unit"] = unit schedule, status = await self.request( From cc28d77e7556af1c79c28b8e837453960e527efe Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 21:23:17 +0100 Subject: [PATCH 079/129] style: black Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 57c46595..65797a6e 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -178,7 +178,7 @@ async def trigger_schedule( # Support for J energy unit (FM server scheduling trigger endpoint only accepts kWh and MWh) if self.energy_unit == "J": - f = 3.6 * 10 ** 6 + f = 3.6 * 10**6 energy_unit = "kWh" soc_at_start /= f soc_min /= f From 07608256240c3e024cfeafecf6d69f6c044d38e9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 10 Mar 2026 21:31:05 +0100 Subject: [PATCH 080/129] fix: mistake while refactoring (or from running black?) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index eca21e8c..7b3cc149 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -610,9 +610,7 @@ async def get_schedule( """ params = {} if duration is not None: - params["duration"] = ( - pd.Timedelta(duration).isoformat(), - ) # for example: PT1H + params["duration"] = pd.Timedelta(duration).isoformat() # for example: PT1H if unit is not None: await self.ensure_server_version() if Version(self.server_version) < Version("0.32.0"): From 67cbd0b459fb32a9bec5dc798c2beb579fc57d91 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 11 Mar 2026 15:15:49 +0100 Subject: [PATCH 081/129] fix: misinterpreted the usage forecast scale Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 65797a6e..8f9a1780 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -295,16 +295,12 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): fill_level_scale=self._fill_level_scale, ) - # Scale usage forecast e.g. [0, 100] %/s -> [0, 100] %/(15 min) - scale = timedelta(minutes=15) / timedelta(seconds=1) - scaled_usage_forecast = usage_forecast * scale - await self._fm_client.post_sensor_data( sensor_id=self._usage_forecast_sensor_id, start=start_time, prior=self.now(), - values=scaled_usage_forecast.tolist(), - unit=self.power_unit, # e.g. [0, 100] MW/(15 min) + values=usage_forecast.tolist(), + unit=self.power_unit, # e.g. [0, 100] MW/(15 min) # todo: or: f"{self.energy_unit}/s" to scale usage forecast e.g. [0, 100] %/s -> [0, 100] %/(15 min) duration=str(pd.Timedelta(self._resolution) * len(usage_forecast)), ) From 6ba2337a00545bd8e7d34bab2dda116f06ee1c5a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 11 Mar 2026 15:42:07 +0100 Subject: [PATCH 082/129] fix: CEM should only relax soc-constraints (not capacity-constraints and not site-capacity-constraints) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 8f9a1780..d9a10cb9 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -199,7 +199,7 @@ async def trigger_schedule( "production-price": {"sensor": self._price_sensor_id}, "consumption-price": {"sensor": self._price_sensor_id}, "site-power-capacity": f"{3 * 25 * 230} VA", - "relax-constraints": True, + "relax-soc-constraints": True, }, flex_model={ "soc-unit": energy_unit, From 2711aff35ff647e1accfcf31519a8cc74488b22b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 11 Mar 2026 15:44:11 +0100 Subject: [PATCH 083/129] fix: stop flipping the values from the actuator status (now that we use consumption_is_positive on the power sensor Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index d9a10cb9..3a3c98d7 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -112,7 +112,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): self._power_sensor_id, start=start, prior=self.now(), - values=[-power], + values=[power], unit=self.power_unit, duration=timedelta(minutes=15), ) From fbe2461df525673a8fdb26c7145c02d40055ba56 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 12 Mar 2026 10:43:22 +0100 Subject: [PATCH 084/129] feat: use local flexmeasures repo in server and worker Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 93b0e749..0b7db4a0 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -10,8 +10,16 @@ services: server: + volumes: + # A place for config and plugin code, and custom requirements.txt + # The 1st mount point is for running the FlexMeasures CLI, the 2nd for gunicorn + # We use :rw so flexmeasures CLI commands can write log files + - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw + - ./flexmeasures-instance/:/app/instance/:rw + - ../flexmeasures/flexmeasures:/app/flexmeasures:rw command: - | + pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages flexmeasures db upgrade @@ -20,8 +28,14 @@ services: fi gunicorn --bind 0.0.0.0:5000 --worker-tmp-dir /dev/shm --workers 2 --threads 4 wsgi:application worker: + volumes: + # a place for config and plugin code, and custom requirements.txt + - ./flexmeasures-instance/:/usr/var/flexmeasures-instance/:rw + - ../flexmeasures/flexmeasures:/app/flexmeasures:rw + - ./flexmeasures-instance/:/app/instance/:rw command: - | + pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling From a273788c9e3c8f9dff2f5f48821967870f51e1b1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 13 Mar 2026 14:56:19 +0100 Subject: [PATCH 085/129] fix: release step was split off to separate release.yml Signed-off-by: F.N. Claessen --- .github/workflows/ci.yml | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72182174..bee16827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,33 +111,3 @@ jobs: parallel-finished: true github-token: ${{ secrets.GITHUB_TOKEN }} - run: echo "Finished checks" - - publish: - needs: finalize - if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/v') }} - runs-on: ubuntu-latest - environment: - name: release - url: https://pypi.org/project/flexmeasures-client/ - permissions: - id-token: write - steps: - - uses: actions/checkout@v3 - with: {fetch-depth: 0} # deep clone for setuptools-scm - - uses: actions/setup-python@v4 - with: { python-version: "3.11" } - - name: Retrieve pre-built distribution files - uses: actions/download-artifact@v4 - with: - name: python-distribution-files - path: dist/ - - name: Publish Package - uses: pypa/gh-action-pypi-publish@release/v1 - # run: pipx run tox -e publish - - name: Publish release on GitHub - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true - tag_name: ${{ inputs.custom_version || github.ref_name }} - env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }} From d4a72fe82194a37fda55fa90bb691d1d2933b4e6 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 16 Mar 2026 15:49:15 +0100 Subject: [PATCH 086/129] feat: update github workflows. Clean up .pre-commit-config.yaml. Signed-off-by: Stijn van Houwelingen --- .github/workflows/ci.yml | 8 +++----- .github/workflows/release.yml | 2 +- .pre-commit-config.yaml | 35 ++--------------------------------- 3 files changed, 6 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bee16827..92c8f572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: with: enable-cache: true cache-dependency-glob: '.pre-commit-config.yaml' - uv-version: "0.10" + version: "0.10.9" - name: Install dev dependencies run: uv sync --only-group dev --frozen - uses: tox-dev/action-pre-commit-uv@v1 @@ -58,12 +58,11 @@ jobs: name: "Test (${{ matrix.platform }} on Python ${{ matrix.python }})" steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 # needed for hatch-vcs versioning - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true + version: "0.10.9" python-version: ${{ matrix.python }} - name: Install dependencies run: uv sync --frozen --group test @@ -76,12 +75,11 @@ jobs: name: "Test S2 (on Python 3.12)" steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true + version: "0.10.9" python-version-file: ".python-version" - name: Install dependencies run: uv sync --frozen --extra s2 --group test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe318b93..51049da5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 with: - uv-version: "0.10" + version: "0.10.9" python-version-file: ".python-version" enable-cache: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 206147c0..8f85e08b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ exclude: '^docs/conf.py' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: check-added-large-files @@ -15,25 +15,7 @@ repos: - id: end-of-file-fixer - id: requirements-txt-fixer - id: mixed-line-ending - args: ['--fix=auto'] # replace 'auto' with 'lf' to enforce Linux/Mac line endings or 'crlf' for Windows - -## If you want to automatically "modernize" your Python code: -# - repo: https://github.com/asottile/pyupgrade -# rev: v3.3.1 -# hooks: -# - id: pyupgrade -# args: ['--py37-plus'] - -## If you want to avoid flake8 errors due to unused vars or imports: -# - repo: https://github.com/PyCQA/autoflake -# rev: v2.0.0 -# hooks: -# - id: autoflake -# args: [ -# --in-place, -# --remove-all-unused-imports, -# --remove-unused-variables, -# ] + args: ['--fix=auto'] - repo: local hooks: @@ -52,13 +34,6 @@ repos: entry: uv run black types: [python] -## If like to embrace black styles even in the docs: -# - repo: https://github.com/asottile/blacken-docs -# rev: v1.13.0 -# hooks: -# - id: blacken-docs -# additional_dependencies: [black] - - repo: local hooks: - id: flake8 @@ -67,12 +42,6 @@ repos: entry: uv run flake8 types: [python] -## Check for misspells in documentation files: -# - repo: https://github.com/codespell-project/codespell -# rev: v2.2.2 -# hooks: -# - id: codespell - - repo: local hooks: - id: mypy From 06406c98cf8433db41cb46ef923e407fdb3142e9 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 16 Mar 2026 16:03:00 +0100 Subject: [PATCH 087/129] fix: fix s2 tests in CI. Use uv for s2 tests. Signed-off-by: Stijn van Houwelingen --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92c8f572..188ca0cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,11 +84,11 @@ jobs: - name: Install dependencies run: uv sync --frozen --extra s2 --group test - name: Run S2 tests - run: uv run pytest tests/s2 -rFEx --durations 10 --color yes + run: uv run pytest tests/s2 -rFEx --durations 10 --color yes --cov - name: Show coverage report - run: pipx run coverage report + run: uv run coverage report - name: Generate coverage report - run: pipx run coverage lcov -o coverage.lcov + run: uv run coverage lcov -o coverage.lcov - name: Upload partial coverage report uses: coverallsapp/github-action@v2 with: From 274bbc687c61aa436422d36871dc5604e001a307 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 16 Mar 2026 16:04:09 +0100 Subject: [PATCH 088/129] feat: use uv for readthedocs Signed-off-by: Stijn van Houwelingen --- .readthedocs.yml | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index a2bcab30..15001b40 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,24 +4,20 @@ # Required version: 2 -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/conf.py - -# Build documentation with MkDocs -#mkdocs: -# configuration: mkdocs.yml - # Optionally build your docs in additional formats such as PDF formats: - pdf build: - os: ubuntu-22.04 - tools: - python: "3.11" - -python: - install: - - requirements: docs/requirements.txt - - {path: ., method: pip} + os: ubuntu-lts-latest + tools: + python: "3.12" + jobs: + pre_create_environment: + - asdf plugin add uv + - asdf install uv 0.10.9 + - asdf global uv 0.10.9 + create_environment: + - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" + install: + - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --only-group docs --frozen From 0de29b8bf1c371220692bf4b268bbcaae99d181b Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Mon, 16 Mar 2026 16:43:22 +0100 Subject: [PATCH 089/129] refactor: remove coverage for s2. Add coverage to general test Signed-off-by: Stijn van Houwelingen --- .github/workflows/ci.yml | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 188ca0cf..b35f79ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,6 @@ jobs: platform: - ubuntu-latest - macos-latest -# - windows-latest runs-on: ${{ matrix.platform }} name: "Test (${{ matrix.platform }} on Python ${{ matrix.python }})" steps: @@ -67,7 +66,19 @@ jobs: - name: Install dependencies run: uv sync --frozen --group test - name: Run tests - run: uv run pytest tests --ignore=tests/s2 -rFEx --durations 10 --color yes + run: uv run pytest tests --ignore=tests/s2 -rFEx --durations 10 --color yes --cov + - name: Show coverage report + run: uv run coverage report + - name: Generate coverage report + run: uv run coverage lcov -o coverage.lcov + - name: Upload partial coverage report + uses: coverallsapp/github-action@v2 + with: + fail-on-error: false + path-to-lcov: coverage.lcov + github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: ${{ matrix.platform }} - py${{ matrix.python }} + parallel: true test-s2: needs: check @@ -84,19 +95,7 @@ jobs: - name: Install dependencies run: uv sync --frozen --extra s2 --group test - name: Run S2 tests - run: uv run pytest tests/s2 -rFEx --durations 10 --color yes --cov - - name: Show coverage report - run: uv run coverage report - - name: Generate coverage report - run: uv run coverage lcov -o coverage.lcov - - name: Upload partial coverage report - uses: coverallsapp/github-action@v2 - with: - fail-on-error: false - path-to-lcov: coverage.lcov - github-token: ${{ secrets.GITHUB_TOKEN }} - flag-name: ${{ matrix.platform }} - py${{ matrix.python }} - parallel: true + run: uv run pytest tests/s2 -rFEx --durations 10 --color yes finalize: needs: [ test, test-s2] From cd7b73248ccd4b74b7bedb75d0f9d342aa2be6e4 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Wed, 18 Mar 2026 15:36:54 +0100 Subject: [PATCH 090/129] fix: fix issues in S2 client and server examples. Use Pydantic V2 methods for JSON serialization. Use environment variables for server script. Use variable pricing in server script. Delete redundant demo_setup.py. Signed-off-by: Stijn van Houwelingen --- .../s2/script/demo_setup.py | 47 ----------------- .../s2/script/websockets_client.py | 26 +++++----- .../s2/script/websockets_server.py | 50 ++++++++++++++++--- 3 files changed, 54 insertions(+), 69 deletions(-) delete mode 100644 src/flexmeasures_client/s2/script/demo_setup.py diff --git a/src/flexmeasures_client/s2/script/demo_setup.py b/src/flexmeasures_client/s2/script/demo_setup.py deleted file mode 100644 index 3524c037..00000000 --- a/src/flexmeasures_client/s2/script/demo_setup.py +++ /dev/null @@ -1,47 +0,0 @@ -import asyncio - -from flexmeasures_client.client import FlexMeasuresClient - -client = FlexMeasuresClient( - email="admin@admin.com", - password="admin", - host="localhost:5000", -) - - -async def my_script(): - await client.post_measurements( - sensor_id=2, - start="2023-05-14T00:00:00+02:00", - duration="PT24H", - unit="EUR/MWh", - values=[ - 10, - 11, - 12, - 15, - 18, - 17, - 10.5, - 9, - 9.5, - 9, - 8.5, - 10, - 8, - 5, - 4, - 4, - 5.5, - 8, - 12, - 13, - 14, - 12.5, - 10, - 7, - ], - ) - - -asyncio.run(my_script()) diff --git a/src/flexmeasures_client/s2/script/websockets_client.py b/src/flexmeasures_client/s2/script/websockets_client.py index bf990c7b..9e84ac69 100644 --- a/src/flexmeasures_client/s2/script/websockets_client.py +++ b/src/flexmeasures_client/s2/script/websockets_client.py @@ -41,7 +41,7 @@ async def main_s2(): print("SENDING: HANDSHAKE") - await ws.send_json(message.json()) + await ws.send_str(message.model_dump_json()) response = await ws.receive() @@ -64,11 +64,15 @@ async def main_s2(): provides_power_measurement_types=[ CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC ], - ).json() + ).model_dump_json() print("SENDING: ResourceManagerDetails") - await ws.send_json(resource_manager_details_message) + await ws.send_str(resource_manager_details_message) + + response = await ws.receive() + + print("RECEIVING: ", response.json()) response = await ws.receive() @@ -87,7 +91,7 @@ async def main_s2(): ) print("SENDING: ReceptionStatus") - await ws.send_json(message.json()) + await ws.send_str(message.model_dump_json()) electric_power = CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC @@ -98,7 +102,7 @@ async def main_s2(): print("SENDING: FRBC.StorageStatus") - await ws.send_json(storage_status.json()) + await ws.send_str(storage_status.model_dump_json()) response = await ws.receive() @@ -148,21 +152,15 @@ async def main_s2(): valid_from=valid_from, actuators=[actuator], storage=storage, - ).json() + ).model_dump_json() print("SENDING: FRBC.SystemDescription") - await ws.send_json(system_description_message) - - msg = await ws.receive() - print("RECEIVED: ", msg.json()) + await ws.send_str(system_description_message) - for i in range(6): - msg = await ws.receive() + async for msg in ws: print("RECEIVED: ", msg.json()) - await ws.close() - async def main_websocket(): async with aiohttp.ClientSession() as session: diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index afceeb56..6a11c948 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -2,6 +2,7 @@ import json import logging import os +from urllib.parse import urlparse import aiohttp from aiohttp import web @@ -49,7 +50,10 @@ async def websocket_producer(ws, cem: CEM): while not cem.is_closed(): message = await cem.get_message() cem._logger.debug("sending message") - await ws.send_json(message) + try: + await ws.send_json(message) + except aiohttp.ClientConnectionResetError: + break cem._logger.debug("cem closed") @@ -60,17 +64,17 @@ async def websocket_consumer(ws, cem: CEM): if msg.data == "close": # TODO: save cem state? cem._logger.debug("close...") - cem.close() + await cem.close() await ws.close() else: try: - await cem.handle_message(json.loads(msg.json())) - except TypeError: - await cem.handle_message(msg.json()) + await cem.handle_message(json.loads(msg.data)) + except (json.JSONDecodeError, KeyError): + cem._logger.exception(f"handle_message failed for {msg.data!r}") elif msg.type == aiohttp.WSMsgType.ERROR: cem._logger.debug("close...") - cem.close() + await cem.close() cem._logger.error(f"ws connection closed with exception {ws.exception()}") # TODO: save cem state? @@ -82,8 +86,13 @@ async def websocket_handler(request): await ws.prepare(request) site_name = "My CEM" + base_url = os.getenv("FLEXMEASURES_BASE_URL", "http://localhost:5000") + parsed = urlparse(base_url) fm_client = FlexMeasuresClient( - "toy-password", "toy-user@flexmeasures.io", host="server:5000" + password=os.getenv("FLEXMEASURES_PASSWORD", "toy-password"), + email=os.getenv("FLEXMEASURES_USER", "toy-user@flexmeasures.io"), + host=parsed.netloc, + ssl=parsed.scheme == "https", ) price_sensor, power_sensor, soc_sensor, rm_discharge_sensor = await configure_site( @@ -168,7 +177,32 @@ async def configure_site( sensor_id=price_sensor["id"], start="2026-01-15T00:00+01", # 2026-01-01T00:00+01 duration="P3D", # P1M - values=[0.3], + values=[ + 0.10, + 0.11, + 0.12, + 0.15, + 0.18, + 0.17, + 0.11, + 0.09, + 0.10, + 0.09, + 0.09, + 0.10, + 0.08, + 0.05, + 0.04, + 0.04, + 0.06, + 0.08, + 0.12, + 0.13, + 0.14, + 0.13, + 0.10, + 0.07, + ], unit="EUR/kWh", ) if power_sensor is None: From 1ec5a87b9c987c43cd0cf0e8215553e795266c0e Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Wed, 18 Mar 2026 15:39:58 +0100 Subject: [PATCH 091/129] feat: update docs to use new UV setup Signed-off-by: Stijn van Houwelingen --- .github/agents/copilot.md | 23 +++++---- CONTRIBUTING.rst | 98 ++++++++++++++++----------------------- README.rst | 37 +++++++++------ docs/HEMS.rst | 18 +++---- 4 files changed, 87 insertions(+), 89 deletions(-) diff --git a/.github/agents/copilot.md b/.github/agents/copilot.md index 386c9675..054e1971 100644 --- a/.github/agents/copilot.md +++ b/.github/agents/copilot.md @@ -19,25 +19,30 @@ The main client class is `FlexMeasuresClient` in `src/flexmeasures_client/client ## Running Tests ```bash -pip install -e ".[testing]" -python3 -m pytest tests/test_client.py -q +uv sync --group test +uv run poe test ``` ## Linting -Always fix linting before pushing. Run individually if `pre-commit` is unavailable: +Always fix linting before pushing. Run via pre-commit (preferred): ```bash -pip install black isort flake8 -black src/ tests/ -isort src/ tests/ -flake8 src/ tests/ +uv run poe lint ``` -Or via pre-commit (preferred): +Or run individually using `uv run`: ```bash -pip install pre-commit && pre-commit run --all-files +uv run black src/ tests/ +uv run isort src/ tests/ +uv run flake8 src/ tests/ +``` + +To install pre-commit as a standalone tool: + +```bash +uv tool install pre-commit && pre-commit run --all-files ``` ## Coding Patterns diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index daa7e655..b1f702ec 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -93,9 +93,10 @@ that any documentation update is done in the same way was a code contribution. submit your proposal. When working on documentation changes in your local machine, you can -compile them using |tox|_:: +compile them using Sphinx:: - tox -e docs + uv sync --group docs + uv run sphinx-build docs docs/_build/html and use Python's built-in web server for a preview in your web browser (``http://localhost:8000``):: @@ -122,17 +123,20 @@ This often provides additional considerations and avoids unnecessary work. Create an environment --------------------- -Before you start coding, we recommend creating an isolated `virtual -environment`_ to avoid any problems with your installed Python packages. -This can easily be done via either |virtualenv|_:: +We use the excellent `uv `_ tool to manage our dependencies. +First, `install uv `_. - virtualenv - source /bin/activate +Before you start coding, install all dependencies including the ones needed for development: -or Miniconda_:: +.. code-block:: bash - conda create -n flexmeasures-client python=3 six virtualenv pytest pytest-cov - conda activate flexmeasures-client + uv sync --group dev --group test + +This will also create an isolated virtual environment automatically. + +.. note:: + + If you prefer shorter commands during interactive development, you can either activate the virtual environment (``source .venv/bin/activate``, or ``.venv\\Scripts\\activate`` on Windows) or prefix commands with ``uv run``. Clone the repository -------------------- @@ -147,7 +151,7 @@ Clone the repository #. You should run:: - pip install -U pip setuptools -e . + uv sync --group dev --group test to be able to import the package under development in the Python REPL. @@ -155,7 +159,7 @@ Clone the repository #. Install |pre-commit|_:: - pip install pre-commit + uv tool install pre-commit pre-commit install ``flexmeasures-client`` comes with a lot of hooks configured to automatically help the @@ -201,9 +205,7 @@ Implement your changes #. Please check that your changes don't break any unit tests with:: - tox - - (after having installed |tox|_ with ``pip install tox`` or ``pipx``). + uv run poe test You can also use |tox|_ to run several other pre-configured tasks in the repository. Try ``tox -av`` to see a list of the available checks. @@ -239,32 +241,20 @@ package: ``.eggs``, as well as the ``*.egg-info`` folders in the ``src`` folder or potentially in the root of your project. -#. Sometimes |tox|_ misses out when new dependencies are added, especially to - ``setup.cfg`` and ``docs/requirements.txt``. If you find any problems with - missing dependencies when running a command with |tox|_, try to recreate the - ``tox`` environment using the ``-r`` flag. For example, instead of:: - - tox -e docs - - Try running:: +#. If you find any problems with missing dependencies, try recreating the + virtual environment by removing it and re-running ``uv sync``:: - tox -r -e docs + rm -rf .venv + uv sync --group dev --group test -#. Make sure to have a reliable |tox|_ installation that uses the correct - Python version (e.g., 3.7+). When in doubt you can run:: +#. Make sure ``uv`` is up to date:: - tox --version - # OR - which tox + uv self update - If you have trouble and are seeing weird errors upon running |tox|_, you can - also try to create a dedicated `virtual environment`_ with a |tox|_ binary - freshly installed. For example:: + If you have trouble with the virtual environment, you can verify which Python + is being used:: - virtualenv .venv - source .venv/bin/activate - .venv/bin/pip install tox - .venv/bin/tox -e all + uv run python --version #. `Pytest can drop you`_ in an interactive session in the case an error occurs. In order to do that you need to pass a ``--pdb`` option (for example by @@ -278,27 +268,21 @@ Maintainer tasks Releases -------- -.. todo:: This section assumes you are using PyPI to publicly release your package. - - If instead you are using a different/private package index, please update - the instructions accordingly. - -If you are part of the group of maintainers and have correct user permissions -on PyPI_, the following steps can be used to release a new version for -``flexmeasures-client``: - -#. Make sure all unit tests are successful. -#. Tag the current commit on the main branch with a release tag, e.g., ``v1.2.3``. -#. Push the new tag to the upstream repository_, e.g., ``git push upstream v1.2.3`` -#. Clean up the ``dist`` and ``build`` folders with ``tox -e clean`` - (or ``rm -rf dist build``) - to avoid confusion with old builds and Sphinx docs. -#. Run ``tox -e build`` and check that the files in ``dist`` have - the correct version (no ``.dirty`` or git_ hash) according to the git_ tag. - Also check the sizes of the distributions, if they are too big (e.g., > - 500KB), unwanted clutter may have been accidentally included. -#. Run ``tox -e publish -- --repository pypi`` and check that everything was - uploaded to PyPI_ correctly. +Releases are handled automatically by the CI pipeline (``release.yml``). To publish a new release: + +#. Make sure all unit tests are successful on ``main``. +#. Tag the commit with a version tag and push it:: + + git tag -s -a vX.Y.Z -m "Short summary" + git push upstream vX.Y.Z + +Or make a new release in the GitHub UI. + +The CI will then automatically: + +- Build the distribution with ``uv build`` +- Publish it to PyPI_ using trusted publishing (no API token required) +- Create a GitHub release with auto-generated release notes diff --git a/README.rst b/README.rst index 5ac539c3..a1c0e3de 100644 --- a/README.rst +++ b/README.rst @@ -45,17 +45,19 @@ Installation =============== -Install using ``pip``: +We use `uv `_ to manage dependencies. First, `install uv `_. + +Then add it to your project: .. code-block:: bash - pip install flexmeasures-client + uv add flexmeasures-client The FlexMeasures Client can also run as an `S2 CEM `_. To enable S2 features, you need to install extra requirements: .. code-block:: bash - pip install flexmeasures-client[s2] + uv add flexmeasures-client[s2] Initialization and authentication @@ -245,17 +247,23 @@ The client will re-try until the schedule is available or the ``MAX_POLLING_STEP Development ============== -If you want to develop this package it's necessary to install testing requirements: +We use `uv `_ to manage dependencies. First, `install uv `_. + +To install the package with all development and testing dependencies: .. code-block:: bash - pip install -e ".[testing]" + uv sync --group dev --group test Moreover, if you need to work on S2 features, you need to install extra dependencies: .. code-block:: bash - pip install -e ".[s2, testing]" + uv sync --extra s2 --group dev --group test + +.. note:: + + If you prefer shorter commands during interactive development, you can activate the virtual environment (``source .venv/bin/activate``, or ``.venv\\Scripts\\activate`` on Windows) or run commands directly with ``uv run ``. @@ -268,33 +276,32 @@ Making Changes & Contributing .. note: Read more details in CONTRIBUTING.rst -Install the project locally (in a virtual environment of your choice): +Install the project locally (creating a virtual environment automatically): .. code-block:: bash - pip install -e . + uv sync -Running tests locally is crucial as well. Staying close to the CI workflow: +Running tests locally is crucial as well: .. code-block:: bash - pip install tox - tox -e clean,build - tox -- -rFEx --durations 10 --color yes + uv run poe test -For S2 features, you need to add `-e s2` to tox: +For S2 features: .. code-block:: bash - tox -e s2 + uv sync --extra s2 --group test + uv run poe test-s2 This project uses `pre-commit`_, please make sure to install it before making any changes: .. code-block:: bash - pip install pre-commit + uv tool install pre-commit cd flexmeasures-client pre-commit install diff --git a/docs/HEMS.rst b/docs/HEMS.rst index 340e3006..3a43e858 100644 --- a/docs/HEMS.rst +++ b/docs/HEMS.rst @@ -21,21 +21,23 @@ This is the resulting dashboard: Set up your environment ======================== -To run the HEMS example (``HEMS_setup.py``), you'll need a virtual environment in which both ``flexmeasures`` (the server) and ``flexmeasures-client`` is installed. +To run the HEMS example (``HEMS_setup.py``), you'll need an environment in which both ``flexmeasures`` (the server) and ``flexmeasures-client`` is installed. + +We use `uv `_ to manage dependencies. First, `install uv `_. + +From the ``flexmeasures-client`` repository, install the client and the FlexMeasures server: .. code-block:: bash - python3.12 -m venv venv - pip install -e . - pip install git+https://github.com/flexmeasures/flexmeasures.git@main + uv sync + uv add git+https://github.com/flexmeasures/flexmeasures.git@main -Or, alternatively, for developers: +Or, alternatively, to install released versions into a fresh project: .. code-block:: bash - python3.12 -m venv venv - pip install flexmeasures-client - pip install flexmeasures + uv init my-hems && cd my-hems + uv add flexmeasures-client flexmeasures Next steps: From 3afc4afa155ff8a9cc63fa133a6a20acaf7c6cf3 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Wed, 18 Mar 2026 15:41:35 +0100 Subject: [PATCH 092/129] fix: fix docker compose CEM not connecting to flexmeasures server. Fix miscellaneous docker compose issues Signed-off-by: Stijn van Houwelingen --- docker-compose.yml | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 02d12369..7d9f25a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,11 @@ # ------------------------------------------------------------------ -# This allow to run the S2 CEM from your local FlexMeasures Client code in a docker compose stack. -# Assuming you have flexmeasures the repo next to your flexmeasures-client repo, -# run this from the flexmeasures folder (which contains the Dockerfile): -# docker compose \ -# -f docker-compose.yml \ -# -f ../flexmeasures-client/docker-compose.yml \ -# up +# Run the S2 CEM alongside a running FlexMeasures server stack. +# +# First, start the FlexMeasures server from the flexmeasures folder: +# docker compose up +# +# Then, from this (flexmeasures-client) folder: +# docker compose up # ------------------------------------------------------------------ services: @@ -14,20 +14,12 @@ services: context: . dockerfile: Dockerfile restart: always - ports: - - "8080:8080" # aiohttp default; adjust if you change it + network_mode: host environment: - # Optional, but useful if you later make this configurable - FLEXMEASURES_BASE_URL: http://server:5000 + FLEXMEASURES_BASE_URL: http://localhost:5000 FLEXMEASURES_USER: toy-user@flexmeasures.io FLEXMEASURES_PASSWORD: toy-password LOGGING_LEVEL: INFO - volumes: - # If flexmeasures_client lives in your repo and you want live edits - - flexmeasures-client:/app/flexmeasures-client:rw entrypoint: ["/bin/sh", "-c"] command: - python3 /app/src/flexmeasures_client/s2/script/websockets_server.py - -volumes: - flexmeasures-client: From db0c676860f3d629419d1bb194ed5ed42790edc5 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Wed, 18 Mar 2026 15:42:36 +0100 Subject: [PATCH 093/129] feat: rewrite CEM docs to use new UV setup and new docker compose setup. Remove docs about deprecated demo file. Signed-off-by: Stijn van Houwelingen --- docs/CEM.rst | 73 +++++++++++++++++++++------------------------------- 1 file changed, 29 insertions(+), 44 deletions(-) diff --git a/docs/CEM.rst b/docs/CEM.rst index 72661c75..ace53690 100644 --- a/docs/CEM.rst +++ b/docs/CEM.rst @@ -3,79 +3,64 @@ CEM tutorial ------------- -To run the FlexMeasures Client as a local S2 Customer Energy Manager (CEM) using WebSocket communication: +There are two ways to run the FlexMeasures Client as a local S2 Customer Energy Manager (CEM) using WebSocket communication. -.. code-block:: bash - - pip install flexmeasures-client[s2] - -Then point your Resource Managers (RMs) to ``http://localhost:8080/ws`` and run: - -.. code-block:: bash +Docker Compose +-------------- - python3 flexmeasures_client/s2/scripts/websockets_server.py +This is the easiest way to run the CEM alongside a full FlexMeasures server stack. -We also included a ``docker-compose.yaml`` that can be used to set up the CEM including the FlexMeasures server, creating a fully self-hosted HEMS. -Assuming your ``flexmeasures`` and ``flexmeasures-client`` repo folders are located side by side, run this from your flexmeasures folder: +First, start the FlexMeasures server from the ``flexmeasures`` folder: .. code-block:: bash - docker compose \ - -f docker-compose.yml \ - -f ../flexmeasures-client/docker-compose.yml \ - up - + docker compose up -This creates the following containers for the CEM: +This starts: -- a WebSocket server (FlexMeasures Client) -- web and worker servers (FlexMeasures) -- a database server (Postgres) -- a queue server (Redis) -- a mail server (MailHog) +- Web and worker servers (FlexMeasures) +- Database server (Postgres) +- Queue server (Redis) +- Mail server (MailHog) -To test, run the included example RM: +Once the server is running, start the CEM from this (``flexmeasures-client``) folder: .. code-block:: bash - python3 flexmeasures_client/s2/script/websockets_client.py - -Disclaimer -========== + docker compose up -The `S2 Protocol `_ integration is still under active development. Please, beware that the logic and interfaces can change. +The CEM runs with host networking, so it can reach the FlexMeasures server at ``http://localhost:5000``. +TODO: fix networking. -Run Demo -========= +You can now point your Resource Managers (RMs) to ``http://localhost:8080/ws``. -Run the following commands in the flexmeasures folder to create a toy-account and an admin user: +Local development +------------------ -.. code-block:: bash +If you want to run the CEM locally for development purposes, you can do the following: - flexmeasures add toy-account - flexmeasures add user --username admin --account-id 1 --email admin@mycompany.io --roles admin +First, follow the instructions in the FlexMeasures repository to set up the server. -Launch server: +Then, in the folder containing this repository, run: .. code-block:: bash - flexmeasures run + uv sync --extra s2 -To load the data, run the following command in the flexmeasures-client repository: +Then point your Resource Managers (RMs) to ``http://localhost:8080/ws`` and run: .. code-block:: bash - python src/flexmeasures_client/s2/script/demo_setup.py + uv run src/flexmeasures_client/s2/script/websockets_server.py -Start the S2 server: +To test, run the included example RM: .. code-block:: bash - python src/flexmeasures_client/s2/script/websockets_server.py + uv run src/flexmeasures_client/s2/script/websockets_client.py -In a separate window, start the S2 Client: - -.. code-block:: bash +Disclaimer +========== - python src/flexmeasures_client/s2/script/websockets_client.py +The `S2 Protocol `_ integration is still under active development. Please, beware that the logic and interfaces can change. From 030c6bf12179688f1756708412ed17a3a6de6529 Mon Sep 17 00:00:00 2001 From: Stijn van Houwelingen Date: Wed, 18 Mar 2026 15:43:14 +0100 Subject: [PATCH 094/129] fix: trigger_schedule retreiving outdated schedule Signed-off-by: Stijn van Houwelingen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 6e81f291..0502405d 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -124,6 +124,7 @@ async def trigger_schedule(self, system_description_id: str): "soc-max": soc_max, }, duration=self._schedule_duration, # next 12 hours + prior=self.now(), # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, # this needs changes on the client ) From b78bacfa71f68338f60c60ddda82664a8e77cdc4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 14:27:37 +0200 Subject: [PATCH 095/129] dev: expose dev-db on local port Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 0b7db4a0..037690c1 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -9,6 +9,9 @@ # ------------------------------------------------------------------ services: + dev-db: + ports: + - "5432:5432" server: volumes: # A place for config and plugin code, and custom requirements.txt From 0e4489a80a4e4634acba731131a2e46cc0d07bdc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 15:58:23 +0200 Subject: [PATCH 096/129] fix: missing import Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index a684297e..fb15f9a5 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -3,7 +3,7 @@ import json import logging import math -from asyncio import Queue +import asyncio from collections import defaultdict from datetime import datetime, timedelta from logging import Logger @@ -84,7 +84,7 @@ def __init__( super(CEM, self).__init__() self._fm_client = fm_client - self._sending_queue = Queue() + self._sending_queue = asyncio.Queue() self._power_sensors = dict() self.power_sensor_id = power_sensor_id self._control_types_handlers = dict() From f3114cc683f8ae87cc99486f342005a0dcc9900e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 15:59:10 +0200 Subject: [PATCH 097/129] dev: fix db port exposure Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 037690c1..dd604844 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -11,7 +11,7 @@ services: dev-db: ports: - - "5432:5432" + - "5433:5432" server: volumes: # A place for config and plugin code, and custom requirements.txt From da7268a8eb414c6014d1961fa42959393b4dec32 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 15:59:27 +0200 Subject: [PATCH 098/129] dev: expose queue-db port, too Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index dd604844..64212113 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -12,6 +12,9 @@ services: dev-db: ports: - "5433:5432" + queue-db: + ports: + - "6380:6379" server: volumes: # A place for config and plugin code, and custom requirements.txt From 21d28a1223ce3bc3cae95d644d19cbd4d7e0beb2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 16:00:03 +0200 Subject: [PATCH 099/129] fix: install missing packages for CEM Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 64212113..6b1403a7 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -60,6 +60,7 @@ services: FLEXMEASURES_USER: toy-user@flexmeasures.io FLEXMEASURES_PASSWORD: toy-password LOGGING_LEVEL: DEBUG + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_FLEXMEASURES_CLIENT: "0.0.0" volumes: # If flexmeasures_client lives in your repo and you want live edits - ../flexmeasures-client:/app/flexmeasures-client:rw @@ -68,4 +69,7 @@ services: - | # pip install --break-system-packages --no-cache-dir "git+https://github.com/FlexMeasures/flexmeasures-client.git@main#egg=flexmeasures-client[s2]" pip install --break-system-packages -e /app/flexmeasures-client[s2] + pip install --break-system-packages aiohttp + pip install --break-system-packages pytz + pip install --break-system-packages s2-python==0.8.2 python3 /app/flexmeasures-client/src/flexmeasures_client/s2/script/websockets_server.py From 78b87d5f9f3c50b0aad26f649a536fb352176d66 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 16:03:35 +0200 Subject: [PATCH 100/129] feat: add charging-efficiency sensor Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 4 ++++ .../s2/script/websockets_server.py | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 3a3c98d7..eefd90f4 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -45,6 +45,7 @@ class FRBCSimple(FRBC): _soc_maxima_sensor_id: int _usage_forecast_sensor_id: int _leakage_behaviour_sensor_id: int + _charging_efficiency_sensor_id: int _schedule_duration: timedelta _fill_level_scale: float _resolution = "15min" @@ -59,6 +60,7 @@ def __init__( soc_maxima_sensor_id: int, usage_forecast_sensor_id: int, leakage_behaviour_sensor_id: int, + charging_efficiency_sensor_id: int, timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), max_size: int = 100, @@ -76,6 +78,7 @@ def __init__( self._soc_maxima_sensor_id = soc_maxima_sensor_id self._usage_forecast_sensor_id = usage_forecast_sensor_id self._leakage_behaviour_sensor_id = leakage_behaviour_sensor_id + self.charging_efficiency_sensor_id = charging_efficiency_sensor_id self._timezone = pytz.timezone(timezone) self._fill_level_scale = fill_level_scale self.power_unit = power_unit @@ -211,6 +214,7 @@ async def trigger_schedule( "state-of-charge": {"sensor": self._soc_sensor_id}, "soc-usage": [{"sensor": self._usage_forecast_sensor_id}], "storage-efficiency": {"sensor": self._leakage_behaviour_sensor_id}, + "charging-efficiency": {"sensor": self.charging_efficiency_sensor_id}, "consumption-capacity": f"{charging_capacity} {self.power_unit}", "production-capacity": f"{discharging_capacity} {self.power_unit}", }, diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 6ef67321..01d60f02 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -105,6 +105,7 @@ async def websocket_handler(request): soc_maxima_sensor, usage_forecast_sensor, leakage_behaviour_sensor_id, + charging_efficiency_sensor, ) = await configure_site(site_name, fm_client) cem = CEM( @@ -121,6 +122,7 @@ async def websocket_handler(request): soc_maxima_sensor_id=soc_maxima_sensor["id"], usage_forecast_sensor_id=usage_forecast_sensor["id"], leakage_behaviour_sensor_id=leakage_behaviour_sensor_id["id"], + charging_efficiency_sensor_id=charging_efficiency_sensor["id"], ) cem.register_control_type(frbc) @@ -136,7 +138,7 @@ async def websocket_handler(request): async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) @@ -171,6 +173,7 @@ async def configure_site( soc_maxima_sensor = None usage_forecast_sensor = None leakage_behaviour_sensor = None + charging_efficiency_sensor = None for sensor in sensors: if sensor["name"] == "price": price_sensor = sensor @@ -272,6 +275,14 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) + if charging_efficiency_sensor is None: + charging_efficiency_sensor = await fm_client.add_sensor( + name="charging-efficiency", + event_resolution="PT15M", + unit="%", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) sensors_to_show = [ { "title": "State of charge", @@ -299,6 +310,7 @@ async def configure_site( soc_maxima_sensor, usage_forecast_sensor, leakage_behaviour_sensor, + charging_efficiency_sensor, ) From 5e2e4cee54d9bf00268c482dbdb86833d6727af3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 16:03:59 +0200 Subject: [PATCH 101/129] refactor: rename variable Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 01d60f02..00ea7ff5 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -104,7 +104,7 @@ async def websocket_handler(request): soc_minima_sensor, soc_maxima_sensor, usage_forecast_sensor, - leakage_behaviour_sensor_id, + leakage_behaviour_sensor, charging_efficiency_sensor, ) = await configure_site(site_name, fm_client) @@ -121,7 +121,7 @@ async def websocket_handler(request): soc_minima_sensor_id=soc_minima_sensor["id"], soc_maxima_sensor_id=soc_maxima_sensor["id"], usage_forecast_sensor_id=usage_forecast_sensor["id"], - leakage_behaviour_sensor_id=leakage_behaviour_sensor_id["id"], + leakage_behaviour_sensor_id=leakage_behaviour_sensor["id"], charging_efficiency_sensor_id=charging_efficiency_sensor["id"], ) cem.register_control_type(frbc) From 72321d4856775f1a7aa3c49a77ebbc0f0c57ce28 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 13 Apr 2026 16:06:36 +0200 Subject: [PATCH 102/129] docs: update developer note Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 00ea7ff5..982fe7a0 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -42,7 +42,7 @@ async def rm_details_watchdog(ws, cem: CEM): cem._logger.debug(f"CONTROL TYPE: {cem._control_type}") - # after this, schedule will be triggered on reception of a new system description + # after this, schedule will be triggered on reception of a new storage status async def websocket_producer(ws, cem: CEM): From 7a87b91e4ce1754e1a5bc9581f3584781ad08fc9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 09:28:19 +0200 Subject: [PATCH 103/129] feat: add production price sensor Signed-off-by: F.N. Claessen --- .../s2/control_types/FRBC/frbc_simple.py | 5 +++- .../s2/script/websockets_server.py | 28 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index eefd90f4..a5c9e088 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -39,6 +39,7 @@ class FRBCSimple(FRBC): _power_sensor_id: int _price_sensor_id: int + _production_price_sensor_id: int _soc_sensor_id: int _rm_discharge_sensor_id: int _soc_minima_sensor_id: int @@ -56,6 +57,7 @@ def __init__( soc_sensor_id: int, rm_discharge_sensor_id: int, price_sensor_id: int, + production_price_sensor_id: int, soc_minima_sensor_id: int, soc_maxima_sensor_id: int, usage_forecast_sensor_id: int, @@ -71,6 +73,7 @@ def __init__( super().__init__(max_size) self._power_sensor_id = power_sensor_id self._price_sensor_id = price_sensor_id + self._production_price_sensor_id = production_price_sensor_id self._schedule_duration = schedule_duration self._soc_sensor_id = soc_sensor_id self._rm_discharge_sensor_id = rm_discharge_sensor_id @@ -199,8 +202,8 @@ async def trigger_schedule( prior=start, sensor_id=self._power_sensor_id, flex_context={ - "production-price": {"sensor": self._price_sensor_id}, "consumption-price": {"sensor": self._price_sensor_id}, + "production-price": {"sensor": self._production_price_sensor_id}, "site-power-capacity": f"{3 * 25 * 230} VA", "relax-soc-constraints": True, }, diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 982fe7a0..c6f5daa5 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -98,6 +98,7 @@ async def websocket_handler(request): ( price_sensor, + production_price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, @@ -116,6 +117,7 @@ async def websocket_handler(request): frbc = FRBCSimple( power_sensor_id=power_sensor["id"], price_sensor_id=price_sensor["id"], + production_price_sensor_id=production_price_sensor["id"], soc_sensor_id=soc_sensor["id"], rm_discharge_sensor_id=rm_discharge_sensor["id"], soc_minima_sensor_id=soc_minima_sensor["id"], @@ -138,7 +140,7 @@ async def websocket_handler(request): async def configure_site( site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict]: +) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict, dict]: account = await fm_client.get_account() assets = await fm_client.get_assets(parse_json_fields=True) @@ -166,6 +168,7 @@ async def configure_site( sensors = site_asset.get("sensors", []) price_sensor = None + production_price_sensor = None power_sensor = None soc_sensor = None rm_discharge_sensor = None @@ -177,6 +180,8 @@ async def configure_site( for sensor in sensors: if sensor["name"] == "price": price_sensor = sensor + if sensor["name"] == "production price": + production_price_sensor = sensor elif sensor["name"] == "power": power_sensor = sensor elif sensor["name"] == "state of charge": @@ -200,6 +205,14 @@ async def configure_site( generic_asset_id=site_asset["id"], timezone="Europe/Amsterdam", ) + if production_price_sensor is None: + production_price_sensor = await fm_client.add_sensor( + name="production price", + event_resolution="PT15M", + unit="EUR/kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) # Continue immediately without awaiting LOGGER.debug("Posting 3 days of prices in a background task..") @@ -218,6 +231,16 @@ async def configure_site( unit="EUR/kWh", ) ) + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=production_price_sensor["id"], + start=start_of_today, + prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 + duration="P3D", # P1M + values=[0.2], + unit="EUR/kWh", + ) + ) if power_sensor is None: power_sensor = await fm_client.add_sensor( name="power", @@ -294,7 +317,7 @@ async def configure_site( }, { "title": "Prices", - "sensor": price_sensor["id"], + "sensors": [price_sensor["id"], production_price_sensor["id"]], }, ] await fm_client.update_asset( @@ -303,6 +326,7 @@ async def configure_site( ) return ( price_sensor, + production_price_sensor, power_sensor, soc_sensor, rm_discharge_sensor, From e700c225492324e8aa4a219037b6f5fa933d4c59 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 10:01:27 +0200 Subject: [PATCH 104/129] fix: get existing charging_efficiency_sensor Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index c6f5daa5..5457edd8 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -196,6 +196,8 @@ async def configure_site( usage_forecast_sensor = sensor elif sensor["name"] == "leakage-behaviour": leakage_behaviour_sensor = sensor + elif sensor["name"] == "charging-efficiency": + charging_efficiency_sensor = sensor if price_sensor is None: price_sensor = await fm_client.add_sensor( From 347268d3face2ce1fca591c43a9daf380fda74cc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 10:33:24 +0200 Subject: [PATCH 105/129] fix: repeated keyword (prior comes from start, not now) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 087ecf39..bd133d21 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -223,7 +223,6 @@ async def trigger_schedule( "production-capacity": f"{discharging_capacity} {self.power_unit}", }, duration=self._schedule_duration, # next 12 hours - prior=self.now(), # TODO: add SOC MAX AND SOC MIN FROM fill_level_range, # this needs changes on the client unit=self.power_unit, From 655ba10d2e716b4ea6eff8b691d47e7eb360949d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 10:39:10 +0200 Subject: [PATCH 106/129] fix: use new internal method Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index e45079b4..cdb64614 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -635,12 +635,9 @@ async def get_schedule( if duration is not None: params["duration"] = pd.Timedelta(duration).isoformat() # for example: PT1H if unit is not None: - await self.ensure_server_version() - if Version(self.server_version) < Version("0.32.0"): - self.logger.warning( - "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " - f"This parameter will be ignored by the server, which is at version {self.server_version}." - ) + message = "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " + f"This parameter will be ignored by the server, which is at version {self.server_version}." + await self.ensure_minimum_server_version("0.32.0", message) params["unit"] = unit schedule, status = await self.request( uri=f"sensors/{sensor_id}/schedules/{schedule_id}", From 74fc8851767034a566816b5744e496e5ab0bbbcd Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 10:42:39 +0200 Subject: [PATCH 107/129] dev: allow using dev server version Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index cdb64614..44826512 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -637,7 +637,7 @@ async def get_schedule( if unit is not None: message = "get_schedule(): The 'unit' parameter requires FlexMeasures server version 0.31.0 or above. " f"This parameter will be ignored by the server, which is at version {self.server_version}." - await self.ensure_minimum_server_version("0.32.0", message) + await self.ensure_minimum_server_version("0.31.0.dev90", message) params["unit"] = unit schedule, status = await self.request( uri=f"sensors/{sensor_id}/schedules/{schedule_id}", From a5b382f611a9dc315c883a2e3cf8005437825cff Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 11:08:44 +0200 Subject: [PATCH 108/129] fix: parse two more JSON fields Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 44826512..f6acf824 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -61,6 +61,8 @@ def _parse_asset_json_fields(asset: dict) -> None: _parse_json_field(asset, "attributes") _parse_json_field(asset, "flex_context") _parse_json_field(asset, "flex_model") + _parse_json_field(asset, "sensors_to_show") + _parse_json_field(asset, "kpi_sensors_to_show") def _parse_sensor_json_fields(sensor: dict) -> None: From 5f62c4182d31531fa4ba7ca53c04c84d16da2926 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 11:09:09 +0200 Subject: [PATCH 109/129] feat: add chart with power values Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/script/websockets_server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index f3bc4c0d..9b7ed1eb 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -327,6 +327,10 @@ async def configure_site( "title": "Prices", "sensors": [price_sensor["id"], production_price_sensor["id"]], }, + { + "title": "Power", + "sensors": [power_sensor["id"]], + }, ] await fm_client.update_asset( asset_id=site_asset["id"], From 69e671e597688e33bcc5bf600245a3cd929e2a02 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 20 Apr 2026 09:49:35 +0200 Subject: [PATCH 110/129] style: black, flake8, isort (run pre-commit hooks) Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 2 +- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 3 +-- src/flexmeasures_client/s2/script/websockets_server.py | 4 +++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index fb15f9a5..109b9eab 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -1,9 +1,9 @@ from __future__ import annotations +import asyncio import json import logging import math -import asyncio from collections import defaultdict from datetime import datetime, timedelta from logging import Logger diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index bd133d21..333ddfe1 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -4,10 +4,9 @@ """ from datetime import datetime, timedelta +from zoneinfo import ZoneInfo import pandas as pd -import pytz -from zoneinfo import ZoneInfo try: from s2python.frbc import ( diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 9b7ed1eb..b48ecb88 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -92,7 +92,9 @@ async def websocket_handler(request): await ws.prepare(request) site_name = "My CEM" - base_url = os.getenv("FLEXMEASURES_BASE_URL", "http://localhost:5000") # or "server:5000" + base_url = os.getenv( + "FLEXMEASURES_BASE_URL", "http://localhost:5000" + ) # or "server:5000" parsed = urlparse(base_url) fm_client = FlexMeasuresClient( password=os.getenv("FLEXMEASURES_PASSWORD", "toy-password"), From 01ccccb3a3eeed1479296c74b0a1bc146400d200 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 21 Apr 2026 17:21:57 +0200 Subject: [PATCH 111/129] fix: hanging future Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 1 - tests/s2/test_cem.py | 38 +++++++++++++++---------------- tests/s2/test_frbc_tunes.py | 10 ++++---- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 109b9eab..14628254 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -442,7 +442,6 @@ async def send_message(self, message): fut = loop.create_future() self._logger.debug(f"Sent: {message}") await self._sending_queue.put((message, fut)) - await fut # wait until actually sent def get_commodity_unit(commodity_quantity) -> str: diff --git a/tests/s2/test_cem.py b/tests/s2/test_cem.py index 37f3aaff..137e9b46 100644 --- a/tests/s2/test_cem.py +++ b/tests/s2/test_cem.py @@ -26,8 +26,8 @@ async def test_handshake(rm_handshake): ) # check that two messages are put to the outgoing queue (ReceptionStatus and HandshakeResponse) # CEM response - response = await cem.get_message() # ReceptionStatus for Handshake - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() # ReceptionStatus for Handshake + response, _ = await cem.get_message() # HandshakeResponse assert ( response["message_type"] == "HandshakeResponse" @@ -54,8 +54,8 @@ async def test_resource_manager_details(resource_manager_details, rm_handshake): cem._sending_queue.qsize() == 2 ) # check that message is put to the outgoing queue - response = await cem.get_message() # ReceptionStatus for Handshake - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() # ReceptionStatus for Handshake + response, _ = await cem.get_message() # HandshakeResponse ########################## # ResourceManagerDetails # @@ -63,7 +63,7 @@ async def test_resource_manager_details(resource_manager_details, rm_handshake): # RM sends ResourceManagerDetails await cem.handle_message(resource_manager_details) - response = await cem.get_message() + response, _ = await cem.get_message() # CEM response is ReceptionStatus with an OK status assert response["message_type"] == "ReceptionStatus" @@ -92,14 +92,14 @@ async def test_activate_control_type( ############# await cem.handle_message(rm_handshake) - response = await cem.get_message() # ReceptionStatus for Handshake - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() # ReceptionStatus for Handshake + response, _ = await cem.get_message() # HandshakeResponse ########################## # ResourceManagerDetails # ########################## await cem.handle_message(resource_manager_details) - response = await cem.get_message() + response, _ = await cem.get_message() ######################### # Activate control type # @@ -107,7 +107,7 @@ async def test_activate_control_type( # CEM sends a request to change te control type await cem.activate_control_type(ControlType.FILL_RATE_BASED_CONTROL) - message = await cem.get_message() + message, _ = await cem.get_message() assert cem.control_type == ControlType.NO_SELECTION, ( "the control type should still be NO_SELECTION (rather than FRBC)," @@ -139,21 +139,21 @@ async def test_messages_route_to_control_type_handler( ############# await cem.handle_message(rm_handshake) - response = await cem.get_message() # ReceptionStatus for Handshake - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() # ReceptionStatus for Handshake + response, _ = await cem.get_message() # HandshakeResponse ########################## # ResourceManagerDetails # ########################## await cem.handle_message(resource_manager_details) - response = await cem.get_message() + response, _ = await cem.get_message() ######################### # Activate control type # ######################### await cem.activate_control_type(ControlType.FILL_RATE_BASED_CONTROL) - message = await cem.get_message() + message, _ = await cem.get_message() response = ReceptionStatus( subject_message_id=message.get("message_id"), status=ReceptionStatusValues.OK @@ -166,7 +166,7 @@ async def test_messages_route_to_control_type_handler( ######## await cem.handle_message(frbc_system_description) - response = await cem.get_message() + response, _ = await cem.get_message() # checking that FRBC handler is being called assert ( @@ -182,7 +182,7 @@ async def test_messages_route_to_control_type_handler( # change of control type is not performed in case that the RM answers # with a negative response await cem.activate_control_type(ControlType.NO_SELECTION) - response = await cem.get_message() + response, _ = await cem.get_message() assert ( cem._control_type == ControlType.FILL_RATE_BASED_CONTROL ), "control type should not change, confirmation still pending" @@ -222,8 +222,8 @@ async def test_automatic_change_control_type(resource_manager_details, rm_handsh cem._sending_queue.qsize() == 2 ) # check that message is put to the outgoing queue - response = await cem.get_message() - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() + response, _ = await cem.get_message() # HandshakeResponse ########################## # ResourceManagerDetails # @@ -231,13 +231,13 @@ async def test_automatic_change_control_type(resource_manager_details, rm_handsh # RM sends ResourceManagerDetails await cem.handle_message(resource_manager_details) - response = await cem.get_message() + response, _ = await cem.get_message() # CEM sends control type on receiving the ResourceManagerDetails assert response["message_type"] == "SelectControlType" assert response["control_type"] == "FILL_RATE_BASED_CONTROL" - response = await cem.get_message() + response, _ = await cem.get_message() # CEM response is ReceptionStatus with an OK status assert response["message_type"] == "ReceptionStatus" diff --git a/tests/s2/test_frbc_tunes.py b/tests/s2/test_frbc_tunes.py index 645692c9..0efb5832 100644 --- a/tests/s2/test_frbc_tunes.py +++ b/tests/s2/test_frbc_tunes.py @@ -78,21 +78,21 @@ async def setup_cem(resource_manager_details, rm_handshake): ############# await cem.handle_message(rm_handshake) - response = await cem.get_message() # ReceptionStatus for Handshake - response = await cem.get_message() # HandshakeResponse + response, _ = await cem.get_message() # ReceptionStatus for Handshake + response, _ = await cem.get_message() # HandshakeResponse ########################## # ResourceManagerDetails # ########################## await cem.handle_message(resource_manager_details) - response = await cem.get_message() + response, _ = await cem.get_message() ######################### # Activate control type # ######################### await cem.activate_control_type(ControlType.FILL_RATE_BASED_CONTROL) - message = await cem.get_message() + message, _ = await cem.get_message() response = ReceptionStatus( subject_message_id=message.get("message_id"), status=ReceptionStatusValues.OK @@ -112,7 +112,7 @@ async def cem_in_frbc_control_type(setup_cem, frbc_system_description): ######## await cem.handle_message(frbc_system_description) - await cem.get_message() + _, _ = await cem.get_message() return cem, fm_client, frbc_system_description From a37c0dfe18ad5bad94773ab8a276eafde73d3355 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 21 Apr 2026 17:51:05 +0200 Subject: [PATCH 112/129] fix: FRBC init Signed-off-by: F.N. Claessen --- tests/s2/test_timezone.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/s2/test_timezone.py b/tests/s2/test_timezone.py index 59ab27b6..11039fcf 100644 --- a/tests/s2/test_timezone.py +++ b/tests/s2/test_timezone.py @@ -16,6 +16,12 @@ def test_frbc_simple_now_uses_zoneinfo_timezone(): soc_sensor_id=2, rm_discharge_sensor_id=3, price_sensor_id=4, + production_price_sensor_id=5, + soc_minima_sensor_id=6, + soc_maxima_sensor_id=7, + usage_forecast_sensor_id=8, + leakage_behaviour_sensor_id=9, + charging_efficiency_sensor_id=10, timezone="Europe/Amsterdam", ) From 094624c5dc9dfaa28109eb5b60f79c4784b802ac Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 18 May 2026 15:23:22 +0200 Subject: [PATCH 113/129] fix: move sensor ID into flex-model Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 2186dd47..b74181bb 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1341,6 +1341,7 @@ async def trigger_schedule( ) self._sensor_asset_id_cache[sensor_id] = sensor["generic_asset_id"] asset_id = self._sensor_asset_id_cache[sensor_id] + flex_model["sensor"] = sensor_id if scheduler is not None: if asset_id is None: From a21c5e475a85ce11ed492a2342abe69a48373316 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 18 May 2026 15:38:38 +0200 Subject: [PATCH 114/129] feat: move to kebab-case field name Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index b74181bb..df981771 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1321,7 +1321,12 @@ async def trigger_schedule( if prior is not None: message["prior"] = pd.Timestamp(prior).isoformat() - message["force_new_job_creation"] = True + if self.server_version is not None and Version( + self.server_version + ) < Version("0.33.0"): + message["force-new-job-creation"] = True + else: + message["force_new_job_creation"] = True # For a sensor_id, try to resolve to the sensor's asset_id so we can use # the asset scheduling endpoint (preferred over the sensor endpoint). From e8c4a884f4dd0cdef0df3aed135b2734eb1c1096 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 1 Jun 2026 13:03:40 +0200 Subject: [PATCH 115/129] fix: prevent using dev version Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 70de4942..db288a55 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -337,6 +337,7 @@ async def request_once( f"FlexMeasures server version changed from {self.server_version} to {header_version}." ) self.server_version = header_version + self.server_version = "0.33.0" # temp override until v0.33.0 is released polling_step, reauth_once, url = await check_response( self, response, polling_step, reauth_once, url From eff597aba5d24c0d961eeaf50e7a38335cbcf04b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 1 Jun 2026 20:06:37 +0200 Subject: [PATCH 116/129] feat: use ingestion queue for flexmeasures v0.33.0 Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 6b1403a7..66c19140 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -44,7 +44,7 @@ services: pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages - flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling + flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling\|ingestion cem: build: context: . From 62b89bce2cb100a7e24e470cc07f51e98a66998e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 3 Jun 2026 16:40:16 +0200 Subject: [PATCH 117/129] fix: prioritize ingestion jobs Signed-off-by: F.N. Claessen --- docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 66c19140..90814ea0 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -44,7 +44,7 @@ services: pip install --break-system-packages -e /app pip install --break-system-packages -r /usr/var/flexmeasures-instance/requirements.txt pip install timely-beliefs -U --break-system-packages - flexmeasures jobs run-worker --name flexmeasures-worker --queue forecasting\|scheduling\|ingestion + flexmeasures jobs run-worker --name flexmeasures-worker --queue ingestion\|scheduling\|forecasting cem: build: context: . From ef3ce3c1c62a600121c7b062b13842e7acab3503 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 09:19:58 +0200 Subject: [PATCH 118/129] feat: make latitude and longitude optional Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index db288a55..224ff314 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -1121,9 +1121,9 @@ async def add_asset( self, name: str, account_id: int, - latitude: float, - longitude: float, generic_asset_type_id: int, + latitude: float = None, + longitude: float = None, parent_asset_id: int | None = None, sensors_to_show: list | None = None, flex_context: dict | None = None, @@ -1152,10 +1152,12 @@ async def add_asset( asset = dict( name=name, account_id=account_id, - latitude=latitude, - longitude=longitude, generic_asset_type_id=generic_asset_type_id, ) + if latitude is not None: + asset["latitude"] = str(latitude) + if longitude is not None: + asset["longitude"] = str(longitude) if parent_asset_id: asset["parent_asset_id"] = parent_asset_id if sensors_to_show: From f68a261404f81a3aba8ec9f6b6ed553b97e57bfc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 8 Jun 2026 09:24:14 +0200 Subject: [PATCH 119/129] feat: map resource to asset when receiving RM details Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 70 +++++ src/flexmeasures_client/s2/config_utils.py | 220 ++++++++++++++++ .../s2/script/websockets_server.py | 239 +----------------- 3 files changed, 291 insertions(+), 238 deletions(-) create mode 100644 src/flexmeasures_client/s2/config_utils.py diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 14628254..84da6a36 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -34,6 +34,7 @@ from flexmeasures_client.client import FlexMeasuresClient from flexmeasures_client.s2 import Handler, register from flexmeasures_client.s2.control_types import ControlTypeHandler +from flexmeasures_client.s2.config_utils import configure_site from flexmeasures_client.s2.utils import ( get_latest_compatible_version, get_reception_status, @@ -320,6 +321,15 @@ async def handle_handshake(self, message: Handshake): async def handle_resource_manager_details(self, message: ResourceManagerDetails): self._resource_manager_details = message + # schedule map_resource_to_asset to run soon concurrently + task = asyncio.create_task(self.map_resource_to_asset(message)) + # self.background_tasks.add( + # task + # ) # important to avoid a task disappearing mid-execution. + # task.add_done_callback(self.background_tasks.discard) + + # await self.map_resource_to_asset(message) + if ( not self._control_type ): # initializing. TODO: check if sending resource_manager_details @@ -332,6 +342,66 @@ async def handle_resource_manager_details(self, message: ResourceManagerDetails) return get_reception_status(message) + async def map_resource_to_asset(self, message): + """Map S2 resource to FM asset. + + - Creates a new asset if the resource ID does not yet exist in FlexMeasures. + - Updates the existing asset if the resource details changed. + - Updates the control type for the resource. + """ + assets = await self._fm_client.get_assets() + asset = None + for ast in assets: + if ast["external_id"] == message.resource_id: + asset = ast + if asset is None: + account = await self._fm_client.get_account() + asset = await self._fm_client.add_asset( + name=message.name, + account_id=account["id"], + generic_asset_type_id=1, + # parent_asset_id=self._asset_id, + attributes=json.loads(message.to_json()), + ) + if asset["name"] != message.name: + await self._fm_client.update_asset( + asset_id=asset["id"], updates={"name": message.name} + ) + if asset["attributes"] != message.to_json(): + await self._fm_client.update_asset( + asset_id=asset["id"], + updates={"attributes": json.loads(message.to_json())}, + ) + + # Reconfigure site + ( + price_sensor, + production_price_sensor, + power_sensor, + soc_sensor, + rm_discharge_sensor, + soc_minima_sensor, + soc_maxima_sensor, + usage_forecast_sensor, + leakage_behaviour_sensor, + charging_efficiency_sensor, + ) = await configure_site(message.name, self._fm_client) + from flexmeasures_client.s2.control_types.FRBC.frbc_simple import FRBCSimple + + frbc = FRBCSimple( + power_sensor_id=power_sensor["id"], + price_sensor_id=price_sensor["id"], + production_price_sensor_id=production_price_sensor["id"], + soc_sensor_id=soc_sensor["id"], + rm_discharge_sensor_id=rm_discharge_sensor["id"], + soc_minima_sensor_id=soc_minima_sensor["id"], + soc_maxima_sensor_id=soc_maxima_sensor["id"], + usage_forecast_sensor_id=usage_forecast_sensor["id"], + leakage_behaviour_sensor_id=leakage_behaviour_sensor["id"], + charging_efficiency_sensor_id=charging_efficiency_sensor["id"], + ) + self.register_control_type(frbc) + @register(PowerMeasurement) async def handle_power_measurement(self, message: PowerMeasurement): diff --git a/src/flexmeasures_client/s2/config_utils.py b/src/flexmeasures_client/s2/config_utils.py new file mode 100644 index 00000000..6edff4a8 --- /dev/null +++ b/src/flexmeasures_client/s2/config_utils.py @@ -0,0 +1,220 @@ +import asyncio +import logging +import os +from datetime import datetime +from zoneinfo import ZoneInfo + +from flexmeasures_client.client import FlexMeasuresClient + +log_level = os.getenv("LOGGING_LEVEL", "WARNING").upper() +logging.basicConfig( + level=log_level, + format="[CEM][%(asctime)s] %(levelname)s: %(name)s | %(message)s", +) +LOGGER = logging.getLogger(__name__) + + +async def configure_site( + site_name: str, fm_client: FlexMeasuresClient +) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict, dict]: + account = await fm_client.get_account() + assets = await fm_client.get_assets(parse_json_fields=True) + + site_asset: dict | None = None + for asset in assets: + if asset["name"] == site_name: + site_asset = asset + break + + site_asset_specs = dict( + latitude=0, + longitude=0, + generic_asset_type_id=6, # Building asset type + flex_model={ + "power-capacity": f"{3 * 25 * 230} VA", + }, + ) + + if not site_asset: + site_asset = await fm_client.add_asset( + name=site_name, account_id=account["id"], **site_asset_specs + ) + # Update site asset with the latest specs + await fm_client.update_asset(site_asset["id"], site_asset_specs) + + sensors = site_asset.get("sensors", []) + price_sensor = None + production_price_sensor = None + power_sensor = None + soc_sensor = None + rm_discharge_sensor = None + soc_minima_sensor = None + soc_maxima_sensor = None + usage_forecast_sensor = None + leakage_behaviour_sensor = None + charging_efficiency_sensor = None + for sensor in sensors: + if sensor["name"] == "price": + price_sensor = sensor + if sensor["name"] == "production price": + production_price_sensor = sensor + elif sensor["name"] == "power": + power_sensor = sensor + elif sensor["name"] == "state of charge": + soc_sensor = sensor + elif sensor["name"] == "RM discharge": + rm_discharge_sensor = sensor + elif sensor["name"] == "soc-minima": + soc_minima_sensor = sensor + elif sensor["name"] == "soc-maxima": + soc_maxima_sensor = sensor + elif sensor["name"] == "usage-forecast": + usage_forecast_sensor = sensor + elif sensor["name"] == "leakage-behaviour": + leakage_behaviour_sensor = sensor + elif sensor["name"] == "charging-efficiency": + charging_efficiency_sensor = sensor + + if price_sensor is None: + price_sensor = await fm_client.add_sensor( + name="price", + event_resolution="PT15M", + unit="EUR/kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if production_price_sensor is None: + production_price_sensor = await fm_client.add_sensor( + name="production price", + event_resolution="PT15M", + unit="EUR/kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + + # Continue immediately without awaiting + LOGGER.debug("Posting 3 days of prices in a background task..") + start_of_today = ( + datetime.now(ZoneInfo("Europe/Amsterdam")) + .replace(hour=0, minute=0, second=0, microsecond=0) + .isoformat() + ) + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=price_sensor["id"], + start=start_of_today, + prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 + duration="P3D", # P1M + values=[0.3], + unit="EUR/kWh", + ) + ) + asyncio.create_task( + fm_client.post_sensor_data( + sensor_id=production_price_sensor["id"], + start=start_of_today, + prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 + duration="P3D", # P1M + values=[0.2], + unit="EUR/kWh", + ) + ) + if power_sensor is None: + power_sensor = await fm_client.add_sensor( + name="power", + event_resolution="PT15M", + unit="kW", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + attributes={"consumption_is_positive": True}, + ) + if soc_sensor is None: + soc_sensor = await fm_client.add_sensor( + name="state of charge", + event_resolution="PT0M", + unit="kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if rm_discharge_sensor is None: + rm_discharge_sensor = await fm_client.add_sensor( + name="RM discharge", + event_resolution="PT15M", + unit="dimensionless", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if soc_minima_sensor is None: + soc_minima_sensor = await fm_client.add_sensor( + name="soc-minima", + event_resolution="PT15M", + unit="kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if soc_maxima_sensor is None: + soc_maxima_sensor = await fm_client.add_sensor( + name="soc-maxima", + event_resolution="PT15M", + unit="kWh", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if usage_forecast_sensor is None: + usage_forecast_sensor = await fm_client.add_sensor( + name="usage-forecast", + event_resolution="PT15M", + unit="kW", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if leakage_behaviour_sensor is None: + leakage_behaviour_sensor = await fm_client.add_sensor( + name="leakage-behaviour", + event_resolution="PT15M", + unit="%", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + if charging_efficiency_sensor is None: + charging_efficiency_sensor = await fm_client.add_sensor( + name="charging-efficiency", + event_resolution="PT15M", + unit="%", + generic_asset_id=site_asset["id"], + timezone="Europe/Amsterdam", + ) + sensors_to_show = [ + { + "title": "State of charge", + "sensors": [ + soc_minima_sensor["id"], + soc_maxima_sensor["id"], + soc_sensor["id"], + ], + }, + { + "title": "Prices", + "sensors": [price_sensor["id"], production_price_sensor["id"]], + }, + { + "title": "Power", + "sensors": [power_sensor["id"]], + }, + ] + await fm_client.update_asset( + asset_id=site_asset["id"], + updates=dict(sensors_to_show=sensors_to_show), + ) + return ( + price_sensor, + production_price_sensor, + power_sensor, + soc_sensor, + rm_discharge_sensor, + soc_minima_sensor, + soc_maxima_sensor, + usage_forecast_sensor, + leakage_behaviour_sensor, + charging_efficiency_sensor, + ) diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index b48ecb88..976c9982 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -2,9 +2,7 @@ import json import logging import os -from datetime import datetime from urllib.parse import urlparse -from zoneinfo import ZoneInfo import aiohttp from aiohttp import web @@ -12,7 +10,6 @@ from flexmeasures_client.client import FlexMeasuresClient from flexmeasures_client.s2.cem import CEM -from flexmeasures_client.s2.control_types.FRBC.frbc_simple import FRBCSimple log_level = os.getenv("LOGGING_LEVEL", "WARNING").upper() logging.basicConfig( @@ -91,7 +88,6 @@ async def websocket_handler(request): ws = web.WebSocketResponse() await ws.prepare(request) - site_name = "My CEM" base_url = os.getenv( "FLEXMEASURES_BASE_URL", "http://localhost:5000" ) # or "server:5000" @@ -104,38 +100,11 @@ async def websocket_handler(request): polling_interval=0.5, ) - ( - price_sensor, - production_price_sensor, - power_sensor, - soc_sensor, - rm_discharge_sensor, - soc_minima_sensor, - soc_maxima_sensor, - usage_forecast_sensor, - leakage_behaviour_sensor, - charging_efficiency_sensor, - ) = await configure_site(site_name, fm_client) - cem = CEM( - sensor_id=power_sensor["id"], + power_sensor_id=None, # assign CEM a top-level asset directly fm_client=fm_client, logger=LOGGER, ) - frbc = FRBCSimple( - power_sensor_id=power_sensor["id"], - price_sensor_id=price_sensor["id"], - production_price_sensor_id=production_price_sensor["id"], - soc_sensor_id=soc_sensor["id"], - rm_discharge_sensor_id=rm_discharge_sensor["id"], - soc_minima_sensor_id=soc_minima_sensor["id"], - soc_maxima_sensor_id=soc_maxima_sensor["id"], - usage_forecast_sensor_id=usage_forecast_sensor["id"], - leakage_behaviour_sensor_id=leakage_behaviour_sensor["id"], - charging_efficiency_sensor_id=charging_efficiency_sensor["id"], - ) - cem.register_control_type(frbc) - # create "parallel" tasks for the message producer and consumer await asyncio.gather( websocket_consumer(ws, cem), @@ -146,212 +115,6 @@ async def websocket_handler(request): return ws -async def configure_site( - site_name: str, fm_client: FlexMeasuresClient -) -> tuple[dict, dict, dict, dict, dict, dict, dict, dict, dict, dict]: - account = await fm_client.get_account() - assets = await fm_client.get_assets(parse_json_fields=True) - - site_asset: dict | None = None - for asset in assets: - if asset["name"] == site_name: - site_asset = asset - break - - site_asset_specs = dict( - latitude=0, - longitude=0, - generic_asset_type_id=6, # Building asset type - flex_model={ - "power-capacity": f"{3 * 25 * 230} VA", - }, - ) - - if not site_asset: - site_asset = await fm_client.add_asset( - name=site_name, account_id=account["id"], **site_asset_specs - ) - # Update site asset with the latest specs - await fm_client.update_asset(site_asset["id"], site_asset_specs) - - sensors = site_asset.get("sensors", []) - price_sensor = None - production_price_sensor = None - power_sensor = None - soc_sensor = None - rm_discharge_sensor = None - soc_minima_sensor = None - soc_maxima_sensor = None - usage_forecast_sensor = None - leakage_behaviour_sensor = None - charging_efficiency_sensor = None - for sensor in sensors: - if sensor["name"] == "price": - price_sensor = sensor - if sensor["name"] == "production price": - production_price_sensor = sensor - elif sensor["name"] == "power": - power_sensor = sensor - elif sensor["name"] == "state of charge": - soc_sensor = sensor - elif sensor["name"] == "RM discharge": - rm_discharge_sensor = sensor - elif sensor["name"] == "soc-minima": - soc_minima_sensor = sensor - elif sensor["name"] == "soc-maxima": - soc_maxima_sensor = sensor - elif sensor["name"] == "usage-forecast": - usage_forecast_sensor = sensor - elif sensor["name"] == "leakage-behaviour": - leakage_behaviour_sensor = sensor - elif sensor["name"] == "charging-efficiency": - charging_efficiency_sensor = sensor - - if price_sensor is None: - price_sensor = await fm_client.add_sensor( - name="price", - event_resolution="PT15M", - unit="EUR/kWh", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if production_price_sensor is None: - production_price_sensor = await fm_client.add_sensor( - name="production price", - event_resolution="PT15M", - unit="EUR/kWh", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - - # Continue immediately without awaiting - LOGGER.debug("Posting 3 days of prices in a background task..") - start_of_today = ( - datetime.now(ZoneInfo("Europe/Amsterdam")) - .replace(hour=0, minute=0, second=0, microsecond=0) - .isoformat() - ) - asyncio.create_task( - fm_client.post_sensor_data( - sensor_id=price_sensor["id"], - start=start_of_today, - prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 - duration="P3D", # P1M - values=[0.3], - unit="EUR/kWh", - ) - ) - asyncio.create_task( - fm_client.post_sensor_data( - sensor_id=production_price_sensor["id"], - start=start_of_today, - prior="2026-01-01T00:00+01", # 2026-01-01T00:00+01 - duration="P3D", # P1M - values=[0.2], - unit="EUR/kWh", - ) - ) - if power_sensor is None: - power_sensor = await fm_client.add_sensor( - name="power", - event_resolution="PT15M", - unit="kW", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - attributes={"consumption_is_positive": True}, - ) - if soc_sensor is None: - soc_sensor = await fm_client.add_sensor( - name="state of charge", - event_resolution="PT0M", - unit="kWh", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if rm_discharge_sensor is None: - rm_discharge_sensor = await fm_client.add_sensor( - name="RM discharge", - event_resolution="PT15M", - unit="dimensionless", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if soc_minima_sensor is None: - soc_minima_sensor = await fm_client.add_sensor( - name="soc-minima", - event_resolution="PT15M", - unit="kWh", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if soc_maxima_sensor is None: - soc_maxima_sensor = await fm_client.add_sensor( - name="soc-maxima", - event_resolution="PT15M", - unit="kWh", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if usage_forecast_sensor is None: - usage_forecast_sensor = await fm_client.add_sensor( - name="usage-forecast", - event_resolution="PT15M", - unit="kW", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if leakage_behaviour_sensor is None: - leakage_behaviour_sensor = await fm_client.add_sensor( - name="leakage-behaviour", - event_resolution="PT15M", - unit="%", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - if charging_efficiency_sensor is None: - charging_efficiency_sensor = await fm_client.add_sensor( - name="charging-efficiency", - event_resolution="PT15M", - unit="%", - generic_asset_id=site_asset["id"], - timezone="Europe/Amsterdam", - ) - sensors_to_show = [ - { - "title": "State of charge", - "sensors": [ - soc_minima_sensor["id"], - soc_maxima_sensor["id"], - soc_sensor["id"], - ], - }, - { - "title": "Prices", - "sensors": [price_sensor["id"], production_price_sensor["id"]], - }, - { - "title": "Power", - "sensors": [power_sensor["id"]], - }, - ] - await fm_client.update_asset( - asset_id=site_asset["id"], - updates=dict(sensors_to_show=sensors_to_show), - ) - return ( - price_sensor, - production_price_sensor, - power_sensor, - soc_sensor, - rm_discharge_sensor, - soc_minima_sensor, - soc_maxima_sensor, - usage_forecast_sensor, - leakage_behaviour_sensor, - charging_efficiency_sensor, - ) - - app = web.Application() app.add_routes([web.get("/ws", websocket_handler)]) web.run_app(app) From c20c2f3338bd8a5ca0e8bf91be05e0e4f5e9a65b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 16 Jun 2026 17:11:00 +0200 Subject: [PATCH 120/129] fix: allow parsing JSON fields when updating a sensor or asset Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 54 ++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 224ff314..bbe115d2 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -760,7 +760,7 @@ async def get_asset( ) -> dict: """Fetch a single asset. - :param asset_id: ID of the asset to fetch + :param asset_id: ID of the asset to fetch. :param parse_json_fields: If True, parse JSON string fields (attributes, flex_context, flex_model) into Python dicts. If False, leave them as JSON strings for backward compatibility. If None (default), uses old behavior (no parsing) with a deprecation warning. @@ -1028,7 +1028,7 @@ async def get_sensor( ) -> dict: """Get a single sensor. - :param sensor_id: ID of the sensor to fetch + :param sensor_id: ID of the sensor to fetch. :param parse_json_fields: If True, parse JSON string fields (attributes) into Python dicts. If False, leave them as JSON strings for backward compatibility. If None (default), uses old behavior (no parsing) with a deprecation warning. @@ -1180,9 +1180,17 @@ async def add_asset( ) return new_asset - async def update_asset(self, asset_id: int, updates: dict) -> dict: + async def update_asset( + self, asset_id: int, updates: dict, parse_json_fields: bool | None = None + ) -> dict: """Patch an asset. + :param asset_id: ID of the asset to update. + :param updates: Dictionary with the fields to update. + :param parse_json_fields: If True, parse JSON string fields (attributes) into Python dicts. + If False, leave them as JSON strings for backward compatibility. + If None (default), uses old behavior (no parsing) with a deprecation warning. + Default will change to True in a future version. :returns: asset as dictionary, for example: { 'account_id': 2, @@ -1236,6 +1244,21 @@ async def update_asset(self, asset_id: int, updates: dict) -> dict: raise ContentTypeError( f"Expected an asset dictionary, but got {type(updated_asset)}", ) + + if parse_json_fields is None: + warnings.warn( + "The default behavior of update_asset() will change in a future version. " + "JSON fields (attributes) will be automatically parsed into Python dicts. " + "To opt into the new behavior now, pass parse_json_fields=True. " + "To silence this warning and keep the old behavior, pass parse_json_fields=False explicitly.", + FutureWarning, + stacklevel=2, + ) + parse_json_fields = False + + if parse_json_fields: + _parse_asset_json_fields(updated_asset) + return updated_asset async def delete_asset(self, asset_id: int, confirm_first: bool = True): @@ -1254,9 +1277,17 @@ async def delete_asset(self, asset_id: int, confirm_first: bool = True): _, status = await self.request(uri=uri, method="DELETE") check_for_status(status, 204) - async def update_sensor(self, sensor_id: int, updates: dict) -> dict: + async def update_sensor( + self, sensor_id: int, updates: dict, parse_json_fields: bool | None = None + ) -> dict: """Patch a sensor. + :param sensor_id: ID of the sensor to update. + :param updates: Dictionary with the fields to update. + :param parse_json_fields: If True, parse JSON string fields (attributes) into Python dicts. + If False, leave them as JSON strings for backward compatibility. + If None (default), uses old behavior (no parsing) with a deprecation warning. + Default will change to True in a future version. :returns: sensor as dictionary, for example: { 'attributes': '{}', @@ -1284,6 +1315,21 @@ async def update_sensor(self, sensor_id: int, updates: dict) -> dict: raise ContentTypeError( f"Expected a sensor dictionary, but got {type(updated_sensor)}", ) + + if parse_json_fields is None: + warnings.warn( + "The default behavior of update_sensor() will change in a future version. " + "JSON fields (attributes) will be automatically parsed into Python dicts. " + "To opt into the new behavior now, pass parse_json_fields=True. " + "To silence this warning and keep the old behavior, pass parse_json_fields=False explicitly.", + FutureWarning, + stacklevel=2, + ) + parse_json_fields = False + + if parse_json_fields: + _parse_sensor_json_fields(updated_sensor) + return updated_sensor async def delete_sensor(self, sensor_id: int, confirm_first: bool = True): From 8cc82d828d265cb711df3f676a32e7f0d505d1d1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 18 Jun 2026 13:35:46 +0200 Subject: [PATCH 121/129] fix: race condition in handler registration Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 42 +++++++------- src/flexmeasures_client/s2/utils.py | 9 ++- tests/s2/test_cem.py | 87 +++++++++++++++++++++++++++-- 3 files changed, 112 insertions(+), 26 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 84da6a36..cf1ef6d2 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -36,6 +36,7 @@ from flexmeasures_client.s2.control_types import ControlTypeHandler from flexmeasures_client.s2.config_utils import configure_site from flexmeasures_client.s2.utils import ( + ControlContext, get_latest_compatible_version, get_reception_status, get_unique_id, @@ -49,6 +50,9 @@ class CEM(Handler): _resource_manager_details: ResourceManagerDetails + _control = ControlContext() + _handler_build_tasks: dict[ControlType, asyncio.Task] = {} + _control_types_handlers: Dict[ControlType | None, ControlTypeHandler] _control_type = None _is_closed = True @@ -142,7 +146,7 @@ def is_closed(self): @property def control_type(self): - return self._control_type + return self._control.control_type def register_control_type(self, control_type_handler: ControlTypeHandler): """ @@ -200,19 +204,17 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): self._logger.debug(f"Received: {message}") # try to handle the message with the control_type handle + ct = self._control.control_type + handler = self._control_types_handlers.get(ct) + ready = self._control.handler_ready.get(ct, False) + if ( - self._control_type is not None - and ( - self._control_type - not in [ControlType.NO_SELECTION, ControlType.NOT_CONTROLABLE] - ) - and self._control_types_handlers[self._control_type].supports_message( - message - ) + handler is not None + and ready + and ct not in [ControlType.NO_SELECTION, ControlType.NOT_CONTROLABLE] + and handler.supports_message(message) ): - response = await self._control_types_handlers[ - self._control_type - ].handle_message(message) + response = await handler.handle_message(message) else: if self.supports_message(message): response = await super().handle_message( @@ -235,7 +237,7 @@ def update_control_type(self, control_type: ControlType): Callback function that is triggered when we receive a confirmation that the message has been received. """ - self._control_type = control_type + self._control.control_type = control_type async def get_message(self) -> tuple[str, asyncio.Future]: """Call this function to get the messages to be sent to the RM @@ -264,7 +266,7 @@ async def activate_control_type( """ # check if it's trying to activate the current control_type - if control_type == self._control_type: + if control_type == self._control.control_type: self._logger.warning(f"RM is already in `{control_type}` control type.") return None @@ -274,14 +276,14 @@ async def activate_control_type( return None # RM initialization succeeded - if self._control_type is not None: + if self._control.control_type is not None: message_id = get_unique_id() # the callback `update_control_type` will be called upon arrival of a # ReceptionStatus message with status = ReceptionStatusValues.OK # register callback in CEM handler - if self._control_type in [ + if self._control.control_type in [ ControlType.NOT_CONTROLABLE, ControlType.NO_SELECTION, ]: @@ -290,7 +292,7 @@ async def activate_control_type( ) else: # register callback in control mode handler self._control_types_handlers[ - self._control_type + self._control.control_type ].register_success_callbacks( message_id, self.update_control_type, control_type=control_type ) @@ -323,6 +325,7 @@ async def handle_resource_manager_details(self, message: ResourceManagerDetails) # schedule map_resource_to_asset to run soon concurrently task = asyncio.create_task(self.map_resource_to_asset(message)) + self._handler_build_tasks[ControlType.FILL_RATE_BASED_CONTROL] = task # self.background_tasks.add( # task # ) # important to avoid a task disappearing mid-execution. @@ -331,10 +334,10 @@ async def handle_resource_manager_details(self, message: ResourceManagerDetails) # await self.map_resource_to_asset(message) if ( - not self._control_type + not self._control.control_type ): # initializing. TODO: check if sending resource_manager_details # resets control type - self._control_type = ControlType.NO_SELECTION + self._control.control_type = ControlType.NO_SELECTION # Activate default control type if defined if self._default_control_type: @@ -401,6 +404,7 @@ async def map_resource_to_asset(self, message): charging_efficiency_sensor_id=charging_efficiency_sensor["id"], ) self.register_control_type(frbc) + self._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True @register(PowerMeasurement) async def handle_power_measurement(self, message: PowerMeasurement): diff --git a/src/flexmeasures_client/s2/utils.py b/src/flexmeasures_client/s2/utils.py index 50eabaab..b7a2f502 100644 --- a/src/flexmeasures_client/s2/utils.py +++ b/src/flexmeasures_client/s2/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import OrderedDict +from dataclasses import dataclass, field from typing import Mapping, TypeVar from uuid import uuid4 @@ -9,7 +10,7 @@ from packaging.version import Version try: - from s2python.common import ReceptionStatus, ReceptionStatusValues + from s2python.common import ControlType, ReceptionStatus, ReceptionStatusValues except ImportError: raise ImportError( "The 's2-python' package is required for this functionality. " @@ -39,6 +40,12 @@ def __setitem__(self, __key: KT, __value: VT) -> None: return super().__setitem__(__key, __value) +@dataclass +class ControlContext: + control_type: ControlType | None = None + handler_ready: dict[ControlType, bool] = field(default_factory=dict) + + def get_unique_id() -> str: """Generate a random v4 UUID string. diff --git a/tests/s2/test_cem.py b/tests/s2/test_cem.py index 137e9b46..eacb2ada 100644 --- a/tests/s2/test_cem.py +++ b/tests/s2/test_cem.py @@ -1,12 +1,28 @@ from __future__ import annotations +import asyncio import pytest +import pytest_asyncio from s2python.common import ControlType, ReceptionStatus, ReceptionStatusValues +from unittest.mock import AsyncMock, MagicMock from flexmeasures_client.s2.cem import CEM from flexmeasures_client.s2.control_types.FRBC import FRBCTest +@pytest_asyncio.fixture +async def cleanup_tasks(): + """Clean up any pending asyncio tasks after each test.""" + yield + # Give any background tasks a chance to complete or fail + await asyncio.sleep(0.1) + # Cancel any remaining tasks + for task in asyncio.all_tasks(): + if not task.done(): + task.cancel() + + + @pytest.mark.asyncio async def test_handshake(rm_handshake): cem = CEM(fm_client=None) @@ -38,7 +54,7 @@ async def test_handshake(rm_handshake): @pytest.mark.asyncio -async def test_resource_manager_details(resource_manager_details, rm_handshake): +async def test_resource_manager_details(resource_manager_details, rm_handshake, cleanup_tasks): cem = CEM(fm_client=None) frbc = FRBCTest() @@ -80,7 +96,7 @@ async def test_resource_manager_details(resource_manager_details, rm_handshake): @pytest.mark.asyncio async def test_activate_control_type( - frbc_system_description, resource_manager_details, rm_handshake + frbc_system_description, resource_manager_details, rm_handshake, cleanup_tasks ): cem = CEM(fm_client=None) frbc = FRBCTest() @@ -127,12 +143,13 @@ async def test_activate_control_type( @pytest.mark.asyncio async def test_messages_route_to_control_type_handler( - frbc_system_description, resource_manager_details, rm_handshake + frbc_system_description, resource_manager_details, rm_handshake, cleanup_tasks ): cem = CEM(fm_client=None) frbc = FRBCTest() cem.register_control_type(frbc) + cem._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True ############# # Handshake # @@ -184,7 +201,7 @@ async def test_messages_route_to_control_type_handler( await cem.activate_control_type(ControlType.NO_SELECTION) response, _ = await cem.get_message() assert ( - cem._control_type == ControlType.FILL_RATE_BASED_CONTROL + cem._control.control_type == ControlType.FILL_RATE_BASED_CONTROL ), "control type should not change, confirmation still pending" await cem.handle_message( @@ -195,7 +212,7 @@ async def test_messages_route_to_control_type_handler( ) assert ( - cem._control_type == ControlType.FILL_RATE_BASED_CONTROL + cem._control.control_type == ControlType.FILL_RATE_BASED_CONTROL ), "control type should not change, confirmation state is not 'OK'" assert ( response.get("message_id") @@ -206,7 +223,7 @@ async def test_messages_route_to_control_type_handler( @pytest.mark.asyncio -async def test_automatic_change_control_type(resource_manager_details, rm_handshake): +async def test_automatic_change_control_type(resource_manager_details, rm_handshake, cleanup_tasks): cem = CEM(fm_client=None, default_control_type=ControlType.FILL_RATE_BASED_CONTROL) frbc = FRBCTest() @@ -242,3 +259,61 @@ async def test_automatic_change_control_type(resource_manager_details, rm_handsh # CEM response is ReceptionStatus with an OK status assert response["message_type"] == "ReceptionStatus" assert response["status"] == "OK" + + +@pytest.mark.asyncio +async def test_handle_message_during_handler_registration_race(): + cem = CEM( + fm_client=MagicMock(), + logger=MagicMock(), + ) + + # --- Fake FRBC handler that we will "register late" + frbc_handler = AsyncMock() + frbc_handler._control_type = ControlType.FILL_RATE_BASED_CONTROL + frbc_handler.supports_message.return_value = True + frbc_handler.handle_message.return_value = {"ok": True} + + # Simulate slow map_resource_to_asset + registration_started = asyncio.Event() + registration_continue = asyncio.Event() + + async def slow_map_resource_to_asset(message): + registration_started.set() + await registration_continue.wait() + + cem.register_control_type(frbc_handler) + cem._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True + + cem.map_resource_to_asset = slow_map_resource_to_asset + + # Set control type BEFORE handler exists + cem.update_control_type(ControlType.FILL_RATE_BASED_CONTROL) + + # Start async registration + task = asyncio.create_task( + cem.map_resource_to_asset(MagicMock(resource_id="x", name="test")) + ) + + # Wait until registration has started but not finished + await registration_started.wait() + + # --- THIS is the race moment + msg = {"message_type": "TestMessage", "message_id": "550e8400-e29b-41d4-a716-446655440000"} + + # Should NOT crash even though handler isn't registered yet + await cem.handle_message(msg) + + # A response should be queued (ReceptionStatus with TEMPORARY_ERROR since handler not ready) + response, _ = await cem.get_message() + assert response.get("message_type") == "ReceptionStatus" + assert response.get("status") == "TEMPORARY_ERROR" + + # Now finish registration + registration_continue.set() + await task + + # Now handler should have been called the second time + await cem.handle_message(msg) + + assert frbc_handler.handle_message.called From a09c19f2ee75379dacfda8e456ea8caa6e2b0c1b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 18 Jun 2026 13:44:14 +0200 Subject: [PATCH 122/129] test: fix CEM tests to use ControlContext and cleanup background tasks - Update test assertions to use ._control.control_type instead of ._control_type - Add handler_ready flag initialization for tests that register but don't await map_resource_to_asset - Add background task cleanup after tests that create background tasks - All CEM tests pass individually --- tests/s2/test_cem.py | 57 +++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/tests/s2/test_cem.py b/tests/s2/test_cem.py index eacb2ada..64bdb26e 100644 --- a/tests/s2/test_cem.py +++ b/tests/s2/test_cem.py @@ -2,7 +2,6 @@ import asyncio import pytest -import pytest_asyncio from s2python.common import ControlType, ReceptionStatus, ReceptionStatusValues from unittest.mock import AsyncMock, MagicMock @@ -10,17 +9,6 @@ from flexmeasures_client.s2.control_types.FRBC import FRBCTest -@pytest_asyncio.fixture -async def cleanup_tasks(): - """Clean up any pending asyncio tasks after each test.""" - yield - # Give any background tasks a chance to complete or fail - await asyncio.sleep(0.1) - # Cancel any remaining tasks - for task in asyncio.all_tasks(): - if not task.done(): - task.cancel() - @pytest.mark.asyncio @@ -54,7 +42,7 @@ async def test_handshake(rm_handshake): @pytest.mark.asyncio -async def test_resource_manager_details(resource_manager_details, rm_handshake, cleanup_tasks): +async def test_resource_manager_details(resource_manager_details, rm_handshake): cem = CEM(fm_client=None) frbc = FRBCTest() @@ -93,10 +81,19 @@ async def test_resource_manager_details(resource_manager_details, rm_handshake, "independently of the original type" ) + # Cleanup: cancel any pending background tasks + for task_id, task in cem._handler_build_tasks.items(): + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + @pytest.mark.asyncio async def test_activate_control_type( - frbc_system_description, resource_manager_details, rm_handshake, cleanup_tasks + frbc_system_description, resource_manager_details, rm_handshake ): cem = CEM(fm_client=None) frbc = FRBCTest() @@ -140,10 +137,19 @@ async def test_activate_control_type( cem.control_type == ControlType.FILL_RATE_BASED_CONTROL ), "after a positive ResponseStatus, the status changes from NO_SELECTION to FRBC" + # Cleanup: cancel any pending background tasks + for task_id, task in cem._handler_build_tasks.items(): + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + @pytest.mark.asyncio async def test_messages_route_to_control_type_handler( - frbc_system_description, resource_manager_details, rm_handshake, cleanup_tasks + frbc_system_description, resource_manager_details, rm_handshake ): cem = CEM(fm_client=None) frbc = FRBCTest() @@ -221,9 +227,18 @@ async def test_messages_route_to_control_type_handler( ].success_callbacks ), "success callback should be deleted" + # Cleanup: cancel any pending background tasks + for task_id, task in cem._handler_build_tasks.items(): + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + @pytest.mark.asyncio -async def test_automatic_change_control_type(resource_manager_details, rm_handshake, cleanup_tasks): +async def test_automatic_change_control_type(resource_manager_details, rm_handshake): cem = CEM(fm_client=None, default_control_type=ControlType.FILL_RATE_BASED_CONTROL) frbc = FRBCTest() @@ -260,6 +275,15 @@ async def test_automatic_change_control_type(resource_manager_details, rm_handsh assert response["message_type"] == "ReceptionStatus" assert response["status"] == "OK" + # Cleanup: cancel any pending background tasks + for task_id, task in cem._handler_build_tasks.items(): + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + @pytest.mark.asyncio async def test_handle_message_during_handler_registration_race(): @@ -317,3 +341,4 @@ async def slow_map_resource_to_asset(message): await cem.handle_message(msg) assert frbc_handler.handle_message.called + From d251fb9dd56f736cf7278f3d4b715af1209f48a4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 18 Jun 2026 17:52:50 +0200 Subject: [PATCH 123/129] refactor: set handler_ready automatically in register_control_type - Move handler_ready flag setting from map_resource_to_asset into register_control_type - This makes the code cleaner and removes redundant manual flag setting in tests - Ensures handler_ready is set atomically with handler registration --- src/flexmeasures_client/s2/cem.py | 4 +++- tests/s2/test_cem.py | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index cf1ef6d2..9c9aa09f 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -174,6 +174,9 @@ def register_control_type(self, control_type_handler: ControlTypeHandler): control_type_handler ) + # Mark handler as ready once registered + self._control.handler_ready[control_type_handler._control_type] = True + async def handle_message(self, message: Dict | pydantic.BaseModel | str): """ This method handles the incoming messages to the CEM and routes them to their custom handler. @@ -404,7 +407,6 @@ async def map_resource_to_asset(self, message): charging_efficiency_sensor_id=charging_efficiency_sensor["id"], ) self.register_control_type(frbc) - self._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True @register(PowerMeasurement) async def handle_power_measurement(self, message: PowerMeasurement): diff --git a/tests/s2/test_cem.py b/tests/s2/test_cem.py index 64bdb26e..2b997efe 100644 --- a/tests/s2/test_cem.py +++ b/tests/s2/test_cem.py @@ -155,7 +155,6 @@ async def test_messages_route_to_control_type_handler( frbc = FRBCTest() cem.register_control_type(frbc) - cem._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True ############# # Handshake # @@ -307,7 +306,6 @@ async def slow_map_resource_to_asset(message): await registration_continue.wait() cem.register_control_type(frbc_handler) - cem._control.handler_ready[ControlType.FILL_RATE_BASED_CONTROL] = True cem.map_resource_to_asset = slow_map_resource_to_asset From 771b9784fa00d8d022cb0abcd2bc604d40aad738 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 18 Jun 2026 17:53:00 +0200 Subject: [PATCH 124/129] fix: initialize ControlContext as instance attribute instead of class attribute Context: - _control and _handler_build_tasks were defined as class attributes - This caused all CEM instances to share the same state - The RM integration test showed 'FRBC not active yet' because stale state persisted between operations Change: - Moved _control and _handler_build_tasks initialization to __init__ as instance attributes - Initialized before super().__init__() to ensure parent's discover() call has access to control_type property - Each CEM instance now has its own isolated ControlContext and handler build tasks dict This was the root cause of the integration test failures where RM would report 'FRBC not active yet'. --- src/flexmeasures_client/s2/cem.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 9c9aa09f..96764297 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -50,8 +50,6 @@ class CEM(Handler): _resource_manager_details: ResourceManagerDetails - _control = ControlContext() - _handler_build_tasks: dict[ControlType, asyncio.Task] = {} _control_types_handlers: Dict[ControlType | None, ControlTypeHandler] _control_type = None @@ -86,6 +84,11 @@ def __init__( """ Customer Energy Manager (CEM) """ + # Initialize per-instance control context and handler build tasks BEFORE calling super().__init__() + # because parent's __init__ calls discover() which accesses control_type property + self._control = ControlContext() + self._handler_build_tasks: dict[ControlType, asyncio.Task] = {} + super(CEM, self).__init__() self._fm_client = fm_client From 61b89c6b6df590a1e6da2ca6a55ccc31753d9ee3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Thu, 18 Jun 2026 17:53:08 +0200 Subject: [PATCH 125/129] fix: remove stale _control_type class attribute and update websockets_server watchdog Context: - _control_type was a class attribute left over from the old code - It was set to None but never updated, causing the watchdog to never activate FRBC - websockets_server.py was checking this stale attribute instead of the new _control.control_type Change: - Removed stale _control_type = None class attribute - Updated rm_details_watchdog() to use _control.control_type instead - This ensures the watchdog properly detects ResourceManagerDetails arrival and sends SelectControlType activation --- src/flexmeasures_client/s2/cem.py | 2 -- src/flexmeasures_client/s2/script/websockets_server.py | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 96764297..d78c9dd9 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -50,9 +50,7 @@ class CEM(Handler): _resource_manager_details: ResourceManagerDetails - _control_types_handlers: Dict[ControlType | None, ControlTypeHandler] - _control_type = None _is_closed = True _default_control_type: ControlType | None diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 976c9982..7277a6d2 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -28,17 +28,17 @@ async def rm_details_watchdog(ws, cem: CEM): """ # wait to get resource manager details - while cem._control_type is None: + while cem._control.control_type is None: await asyncio.sleep(1) await cem.activate_control_type(control_type=ControlType.FILL_RATE_BASED_CONTROL) # check/wait that the control type is set properly - while cem._control_type != ControlType.FILL_RATE_BASED_CONTROL: + while cem._control.control_type != ControlType.FILL_RATE_BASED_CONTROL: cem._logger.debug("waiting for the activation of the control type...") await asyncio.sleep(1) - cem._logger.debug(f"CONTROL TYPE: {cem._control_type}") + cem._logger.debug(f"CONTROL TYPE: {cem._control.control_type}") # after this, schedule will be triggered on reception of a new storage status From d9dc817b33458e64c043120c320aeff68e26a38d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 19 Jun 2026 13:36:13 +0200 Subject: [PATCH 126/129] feat: add conversion efficiency posting for FRBC handlers When the CEM receives a FRBCSystemDescription, it now automatically posts conversion efficiency values to the charging_efficiency_sensor. This is essential for FlexMeasures to properly schedule and optimize charging based on the device's conversion efficiency. ## Changes - Added send_conversion_efficiencies() method to FRBC base class - Added _get_operation_mode_efficiency_sensor_map() for subclasses to override - Updated handle_system_description() to call send_conversion_efficiencies() - Implemented efficiency sensor mapping in FRBCSimple ## How it works When FRBC.SystemDescription is received: 1. Extract operation mode details (fill_rate and power_range) 2. Calculate efficiency: 3600 * fill_rate.end / power_range.end 3. Post efficiency value to the sensor mapped by _get_operation_mode_efficiency_sensor_map() 4. FlexMeasures uses this efficiency data in flex-model calculations ## For FRBCSimple Maps all operation modes to the single charging_efficiency_sensor_id, which is automatically populated by the CEM when configuring the site. All 6 CEM unit tests pass. --- .../s2/control_types/FRBC/__init__.py | 98 +++++++++++++++++++ .../s2/control_types/FRBC/frbc_simple.py | 18 ++++ 2 files changed, 116 insertions(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index 94c85b43..c6ba18b1 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -77,8 +77,106 @@ def handle_system_description( task ) # important to avoid a task disappearing mid-execution. task.add_done_callback(self.background_tasks.discard) + + # schedule send_conversion_efficiencies to run soon concurrently + task = asyncio.create_task(self.send_conversion_efficiencies(message)) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + return get_reception_status(message, status=ReceptionStatusValues.OK) + def _get_operation_mode_efficiency_sensor_map( + self, system_description: FRBCSystemDescription + ) -> dict[str, int]: + """ + Get a mapping of operation mode IDs to efficiency sensor IDs. + + Subclasses can override this method to provide operation mode to efficiency + sensor mappings. Return an empty dict if there are no efficiency sensors. + + Args: + system_description: The system description containing operation mode details. + + Returns: + A dictionary mapping operation mode IDs to efficiency sensor IDs. + Empty dict ({}) if there are no efficiency sensors (default). + """ + return {} + + async def send_conversion_efficiencies( + self, system_description: FRBCSystemDescription + ): + """ + Send conversion efficiencies to FlexMeasures for operation modes. + + This method sends efficiency values for each operation mode that has + an associated efficiency sensor. Subclasses should override + _get_operation_mode_efficiency_sensor_map() to define which operation modes + have efficiency sensors. + + Args: + system_description: The system description containing actuator details. + """ + efficiency_map = self._get_operation_mode_efficiency_sensor_map( + system_description + ) + if not efficiency_map: + # No efficiency sensors defined + return + + try: + from datetime import datetime + from datetime import timedelta + + start = system_description.valid_from + actuator = system_description.actuators[0] + + start_time = start.replace( + minute=(start.minute // 15) * 15, second=0, microsecond=0 + ) + + # Use a default conversion efficiency duration if not set by subclass + duration = getattr(self, "_conversion_efficiency_duration", timedelta(hours=99)) + if isinstance(duration, str): + # If duration is a string like "PT99H", use default timedelta + duration = timedelta(hours=99) + + for operation_mode in actuator.operation_modes: + sensor_id = efficiency_map.get(operation_mode.id) + if sensor_id is None: + # Skip operation modes without an efficiency sensor + continue + + # Calculate efficiency from the last element (characteristic endpoint) + try: + fill_level_scale = getattr(self, "_fill_level_scale", 1.0) + efficiency = ( + 3600 + * operation_mode.elements[-1].fill_rate.end_of_range + * fill_level_scale + / (operation_mode.elements[-1].power_ranges[0].end_of_range) + ) + except (IndexError, AttributeError, ZeroDivisionError) as e: + self._logger.debug( + f"Could not calculate efficiency for operation mode {operation_mode.id}: {e}" + ) + continue + + try: + await self._fm_client.post_sensor_data( + sensor_id=sensor_id, + start=start_time, + values=[efficiency], + unit="dimensionless", + duration=duration, + ) + except Exception as e: + self._logger.debug( + f"Error posting efficiency data for sensor {sensor_id}: {e}" + ) + except Exception as e: + self._logger.debug(f"Error sending conversion efficiencies: {e}") + @register(FRBCUsageForecast) def handle_usage_forecast(self, message: FRBCUsageForecast) -> pydantic.BaseModel: message_id = str(message.message_id) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 333ddfe1..8f4426e4 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -90,6 +90,24 @@ def __init__( def now(self): return datetime.now(self._timezone) + def _get_operation_mode_efficiency_sensor_map(self, system_description) -> dict: + """ + Map operation mode IDs to the charging efficiency sensor. + + For FRBCSimple, all operation modes report their efficiency to the + single charging_efficiency_sensor_id. + """ + efficiency_map = {} + if self.charging_efficiency_sensor_id is None: + return efficiency_map + + # Map each operation mode to the charging efficiency sensor + actuator = system_description.actuators[0] + for operation_mode in actuator.operation_modes: + efficiency_map[operation_mode.id] = self.charging_efficiency_sensor_id + + return efficiency_map + async def send_storage_status(self, status: FRBCStorageStatus): now = self.now() await self._fm_client.post_sensor_data( From cb604f29f0ae66c533f188dbd1b3cf175c473b13 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 22 Jun 2026 13:47:05 +0200 Subject: [PATCH 127/129] dev: default to 24 hour scheduling Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 8f4426e4..44395526 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -64,7 +64,7 @@ def __init__( leakage_behaviour_sensor_id: int, charging_efficiency_sensor_id: int, timezone: str = "UTC", - schedule_duration: timedelta = timedelta(hours=12), + schedule_duration: timedelta = timedelta(hours=24), max_size: int = 100, fill_level_scale: float = 1, power_unit: str = "W", From 8f465ac750a7b7b70200dc92ac8aa658d5e69e5c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 22 Jun 2026 13:48:37 +0200 Subject: [PATCH 128/129] dev: fix charging-efficiency Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index c6ba18b1..07ff3e8e 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -151,7 +151,7 @@ async def send_conversion_efficiencies( try: fill_level_scale = getattr(self, "_fill_level_scale", 1.0) efficiency = ( - 3600 + 1 * operation_mode.elements[-1].fill_rate.end_of_range * fill_level_scale / (operation_mode.elements[-1].power_ranges[0].end_of_range) From 70065dfeac37f87d7e9942567d8f5dc6083e083d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 22 Jun 2026 16:39:21 +0200 Subject: [PATCH 129/129] fix: set belief_time to simulation now, when POSTing charging-efficiency Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/control_types/FRBC/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index 07ff3e8e..de03affd 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -166,6 +166,7 @@ async def send_conversion_efficiencies( await self._fm_client.post_sensor_data( sensor_id=sensor_id, start=start_time, + prior=self.now(), values=[efficiency], unit="dimensionless", duration=duration,