diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8f9f282 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,87 @@ +name: Bug Report +description: Report a bug or unexpected behaviour in wattnet-api. +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to report a bug. Please fill in as much detail as possible to help us reproduce and fix the issue. + + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of what the bug is. + placeholder: "When I call `GET /v1/footprints`, it returns a 500 error even though the API is running." + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Minimal steps needed to reproduce the behaviour. + placeholder: | + 1. Start the API with `wattnet-api` + 2. Send a `GET /v1/footprints?zone=ES` request + 3. Observe error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behaviour + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behaviour + description: What actually happened? Include the full traceback if applicable. + render: text + validations: + required: true + + - type: input + id: version + attributes: + label: wattnet-api Version + placeholder: "e.g. 1.0.0" + validations: + required: true + + - type: input + id: python + attributes: + label: Python Version + placeholder: "e.g. 3.12.3" + validations: + required: true + + - type: input + id: os + attributes: + label: Operating System + placeholder: "e.g. Ubuntu 24.04, macOS 15.2" + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Relevant Logs or Configuration + description: Paste any relevant log output or configuration (redact credentials). + render: text + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional Context + description: Any other context that might help diagnose the issue. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..52af2fa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,58 @@ +name: Feature Request +description: Propose a new feature or improvement for wattnet-api. +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thank you for suggesting an improvement. Please describe your proposal clearly so we can evaluate it. + + - type: textarea + id: problem + attributes: + label: Problem or Motivation + description: What problem does this feature solve, or what use case does it enable? + placeholder: "I need to filter footprints by multiple zones in a single request, but the current API only accepts one zone per call." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the feature or change you would like to see. + placeholder: "Allow the `zone` query parameter to accept a comma-separated list of zone identifiers." + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? + validations: + required: false + + - type: dropdown + id: area + attributes: + label: Area + description: Which part of the project does this relate to? + options: + - API endpoint + - Request / response models + - Authentication / dependencies + - Configuration + - Documentation + - CI / tooling + - Other + validations: + required: true + + - type: textarea + id: context + attributes: + label: Additional Context + description: Any other context, examples, or references that support the request. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..002ab3c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ +## Description + + + +Closes # + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor (no functional change) +- [ ] Documentation +- [ ] CI / tooling +- [ ] Other: + +## Checklist + +- [ ] All existing tests pass (`pytest` / `tox`) +- [ ] New tests added for the changed behaviour +- [ ] Code is formatted (`black wattnet/`, `isort wattnet/`) +- [ ] Linter passes (`flake8 wattnet/`) +- [ ] Type-check passes (`mypy -p wattnet.api`) +- [ ] Security scan passes (`bandit -r wattnet/`) +- [ ] Docstrings updated where needed +- [ ] `CONTRIBUTING.md` still accurate (update if the dev workflow changed) + +## Notes for Reviewers + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bdcf635 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + day: monday + commit-message: + prefix: "build(deps)" + open-pull-requests-limit: 5 + groups: + dev-dependencies: + dependency-type: development + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + commit-message: + prefix: "ci(deps)" + open-pull-requests-limit: 5 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ba804bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,123 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Cache pip + uses: actions/cache@v5 + with: + path: ~/.cache/pip + key: pip-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} + restore-keys: pip-${{ matrix.python-version }}- + + - name: Cache Poetry virtualenvs + uses: actions/cache@v5 + with: + path: ~/.cache/pypoetry/virtualenvs + key: poetry-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} + restore-keys: poetry-${{ matrix.python-version }}- + + - name: Install tox + run: pip install tox tox-gh-actions poetry==2.2.1 + + - name: Run tox + run: tox + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.14' + uses: codecov/codecov-action@v5 + with: + files: coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + integration: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + allow-prereleases: true + + - name: Cache pip + uses: actions/cache@v5 + with: + path: ~/.cache/pip + key: pip-3.14-${{ hashFiles('poetry.lock') }} + restore-keys: pip-3.14- + + - name: Cache Poetry virtualenvs + uses: actions/cache@v5 + with: + path: ~/.cache/pypoetry/virtualenvs + key: poetry-3.14-${{ hashFiles('poetry.lock') }} + restore-keys: poetry-3.14- + + - name: Install tox + run: pip install tox poetry==2.2.1 + + - name: Run integration tests + run: tox -e integration + + wheel: + name: Packaging — verify installed data paths + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + allow-prereleases: true + + - name: Cache pip + uses: actions/cache@v5 + with: + path: ~/.cache/pip + key: pip-3.14-wheel-${{ hashFiles('poetry.lock') }} + restore-keys: pip-3.14-wheel- + + - name: Install tox and Poetry + run: pip install tox poetry==2.2.1 + + - name: Run wheel packaging check + run: tox -e wheel diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..24b5549 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,137 @@ +name: Publish + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + build: + name: Build Python package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + allow-prereleases: true + - name: Install Poetry + run: pip install poetry==2.2.1 + - name: Build + run: poetry build + - uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + + pypi: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/wattnet-api + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true + + docker: + name: Build and push Docker image + needs: build + runs-on: ubuntu-latest + outputs: + digest: ${{ steps.build.outputs.digest }} + permissions: + packages: write + id-token: write + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 + - name: Install cosign + uses: sigstore/cosign-installer@v3 + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to DockerHub + uses: docker/login-action@v4 + with: + registry: docker.io + username: wattnet + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ghcr.io/wattnet/wattnet-api + wattnet/wattnet-api + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + - name: Build and push + id: build + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Sign GHCR image + run: cosign sign --yes ghcr.io/wattnet/wattnet-api@${{ steps.build.outputs.digest }} + - name: Sign DockerHub image + run: cosign sign --yes wattnet/wattnet-api@${{ steps.build.outputs.digest }} + + sbom: + name: Generate and attach SBOMs + needs: [pypi, docker] + runs-on: ubuntu-latest + permissions: + contents: write + packages: read + steps: + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + - name: Generate Python package SBOM + uses: anchore/sbom-action@v0 + with: + path: dist/ + artifact-name: sbom-python.spdx.json + upload-release-assets: true + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Generate Docker image SBOM + uses: anchore/sbom-action@v0 + with: + image: ghcr.io/wattnet/wattnet-api@${{ needs.docker.outputs.digest }} + artifact-name: sbom-docker.spdx.json + upload-release-assets: true diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..3227d66 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,22 @@ +name: Release Please + +on: + push: + branches: ["main"] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + steps: + - uses: googleapis/release-please-action@v5 + id: release + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ab6b844 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +# wattnet-data provides the zone definition files (YAML + GeoJSON) used by the API at runtime. +# Checked out into data/ so the default ZONES_FILE_PATH values in .env.example resolve correctly. +[submodule "data"] + path = data + url = https://github.com/wattnet/wattnet-data.git + branch = main diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ed52694 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +repos: + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.6.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: debug-statements + + - repo: local + hooks: + - id: isort + name: isort + entry: poetry run isort + language: system + types: [python] + + - id: black + name: black + entry: poetry run black + language: system + types: [python] + + - id: flake8 + name: flake8 + entry: poetry run flake8 + language: system + types: [python] + pass_filenames: false + args: [wattnet] diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..37fcefa --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "1.0.0" +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..24bc5ef --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,89 @@ +# Contributor Covenant 3.0 Code of Conduct + +## Our Pledge + +We pledge to make our community welcoming, safe, and equitable for all. + +We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant. + + +## Encouraged Behaviors + +While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language. + +With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including: + +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. + + +## Restricted Behaviors + +We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct. + +1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people. +3. **Stereotyping or discrimination.** Characterizing anyone's personality or behavior on the basis of immutable identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community. +5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. + +### Other Restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content you contribute. +3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community. +4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors. + + +## Reporting an Issue + +Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm. + +When an incident does occur, it is important to report it promptly. To report a possible violation, please contact the project maintainers privately by email at **iglesias@ifca.es**. Do not open a public GitHub issue for code of conduct matters. + +Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution. + + +## Addressing and Repairing Harm + +If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped. + +1) Warning + 1) Event: A violation involving a single incident or series of incidents. + 2) Consequence: A private, written warning from the Community Moderators. + 3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations. +2) Temporarily Limited Activities + 1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation. + 2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members. + 3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over. +3) Temporary Suspension + 1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a single serious violation. + 2) Consequence: A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions. + 3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted. +4) Permanent Ban + 1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the Community Moderators determine there is no way to keep the community safe with this person as a member. + 2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behavior. + 3) Repair: There is no possible repair in cases of this severity. + +This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their discretion and judgment, in keeping with the best interests of our community. + + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public or other spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/). + +Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy of this license, visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/) + +For answers to common questions about Contributor Covenant, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozilla's code of conduct team](https://github.com/mozilla/inclusion). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..18fcbaf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,106 @@ +# Contributing to wattnet-api + +Thank you for your interest in contributing. This document covers how to set up your environment, the code standards we follow, and the process for submitting changes. + +## Prerequisites + +- Python ≥ 3.10 +- [Poetry](https://python-poetry.org/) ≥ 2.0 +- Docker and Docker Compose (for integration tests) +- Git + +## Getting Started + +1. Fork the repository and clone your fork: + + ```bash + git clone https://github.com//wattnet-api.git + cd wattnet-api + ``` + +2. Install all dependency groups: + + ```bash + poetry install --with dev,test,lint,format,types,security + ``` + +3. Install the pre-commit hooks: + + ```bash + pre-commit install --hook-type pre-commit --hook-type commit-msg + ``` + +## Running the Tests + +**Unit tests** (no external services needed): + +```bash +poetry run pytest tests/unit/ +``` + +**Integration tests** (storage layer is mocked — no external services needed): + +```bash +poetry run pytest tests/integration/ +``` + +Or via tox: + +```bash +tox -e integration +``` + +**Full tox matrix** (unit tests on py3.10–3.14, lint, type-check, format, security, dependency audit, build): + +```bash +tox +``` + +## Code Style + +We enforce a consistent style automatically. Before opening a PR, run: + +```bash +# Format code +black wattnet/ +isort wattnet/ + +# Lint +flake8 wattnet/ + +# Type-check +mypy -p wattnet.api + +# Security scan +bandit -r wattnet/ +``` + +All of these also run via pre-commit on every commit and are verified in CI. + +Key rules: +- Line length: 88 characters (Black default). +- Import order: standard library → third-party → first-party (`isort` with Black profile). +- Docstrings: required on all public modules, classes, and functions (`pydocstyle`). +- Commit messages must follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`). + +## Submitting a Pull Request + +1. Create a branch from `main` with a descriptive name: + + ```bash + git checkout -b feat/my-new-feature + ``` + +2. Make your changes, ensuring all tests pass and the linter is clean. + +3. Push your branch and open a PR against `main`. Fill in the PR template. + +4. A maintainer will review your PR. Please address any requested changes promptly. + +## Reporting Issues + +Please use the GitHub issue templates for bug reports and feature requests. For security vulnerabilities, see [SECURITY.md](SECURITY.md). + +## License + +By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f7a8420 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.13-slim + +WORKDIR /app + +RUN pip install --no-cache-dir gunicorn uvicorn[standard] + +COPY dist/wattnet_api-*.whl . +RUN pip install --no-cache-dir wattnet_api-*.whl \ + && rm -f wattnet_api-*.whl + +COPY data /app/data +COPY scripts/docker-entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["entrypoint.sh"] diff --git a/README.md b/README.md index e633d76..22b4a11 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,158 @@ # RESTful API -A comprehensive RESTful API for integrating wattnet into your applications. Query real-time, historical, and forecasted electricity footprints, create dashboards, automate analyses, and extend wattnet’s capabilities. Fully documented with OpenAPI Specification (OAS 3.1) and secured by OAuth 2.0. +[![CI](https://github.com/wattnet/wattnet-api/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wattnet/wattnet-api/actions/workflows/ci.yml) +[![Publish](https://github.com/wattnet/wattnet-api/actions/workflows/publish.yml/badge.svg)](https://github.com/wattnet/wattnet-api/actions/workflows/publish.yml) +[![Release Please](https://github.com/wattnet/wattnet-api/actions/workflows/release-please.yml/badge.svg?branch=main)](https://github.com/wattnet/wattnet-api/actions/workflows/release-please.yml) +[![codecov](https://codecov.io/gh/wattnet/wattnet-api/graph/badge.svg)](https://codecov.io/gh/wattnet/wattnet-api) +[![GitHub stars](https://img.shields.io/github/stars/wattnet/wattnet-api?style=social)](https://github.com/wattnet/wattnet-api/stargazers) +[![PyPI version](https://img.shields.io/pypi/v/wattnet-api)](https://pypi.org/project/wattnet-api/) +[![PyPI Downloads](https://static.pepy.tech/personalized-badge/wattnet-api?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/wattnet-api) +[![Python](https://img.shields.io/pypi/pyversions/wattnet-api)](https://pypi.org/project/wattnet-api/) +[![FastAPI](https://img.shields.io/badge/FastAPI-0.121.1-009688?logo=fastapi)](https://fastapi.tiangolo.com/) +[![OpenAPI 3.1](https://img.shields.io/badge/OpenAPI-3.1-6BA539?logo=openapiinitiative&logoColor=white)](https://www.openapis.org/) +[![Docker Hub](https://img.shields.io/docker/v/wattnet/wattnet-api?sort=semver&logo=docker&label=Docker%20Hub&color=2496ED)](https://hub.docker.com/r/wattnet/wattnet-api) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) -## Interactive API documentation +`wattnet-api` is the public-facing HTTP interface for the Wattnet platform. It exposes real-time, historical, and forecasted data on the carbon and water footprint of electricity consumption across Europe, fully documented with OpenAPI 3.1 and deployable as a Docker container. -Explore the API endpoints, parameters, and responses interactively using the automatically generated Swagger UI documentation: +## Purpose -- **Swagger UI**: [https://api.wattnet.eu/docs](https://api.wattnet.eu/docs) -- **OpenAPI Specification (OAS 3.1)**: [https://api.wattnet.eu/openapi.json](https://api.wattnet.eu/openapi.json) +Wattnet computes environmental metrics — carbon footprint, water impact, green scores, generation mix — from open electricity market data. `wattnet-api` makes all of these metrics available over HTTP so that dashboards, research tools, and third-party applications can query them without coupling to Wattnet's internal data pipeline. -> Note: The API is versioned. Ensure you are using the correct version for your application. The current version is v1. -> Base URL: `https://api.wattnet.eu/v1/` +The API is organized into five groups of endpoints: + +| Group | Prefix | Description | +| ------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------- | +| **Zones** | `/v1/zones` | Metadata and boundaries for supported electricity zones | +| **Energy Metrics** | `/v1/generation`, `/v1/load`, `/v1/imports`, `/v1/exports`, `/v1/mix` | Electricity generation, consumption, and cross-border flows | +| **Environmental Metrics** | `/v1/footprints`, `/v1/impacts`, `/v1/green-score` | Carbon and water footprint, carbon impact, and green score | +| **Shares Metrics** | `/v1/flow-share`, `/v1/mix-share`, `/v1/footprint-share`, `/v1/impact-share` | Fractional attribution of flows, mix, footprint, and impact | +| **Factors** | `/v1/factors` | Emission and consumption factors used in calculations | + +## Architecture + +![API component diagram](https://github.com/wattnet/wattnet-architecture/blob/main/diagrams/png/structurizr-1-wattnet_api.png?raw=true) + +For the full system architecture see the [wattnet-architecture](https://github.com/wattnet/wattnet-architecture) repository. + +`wattnet-api` is a [FastAPI](https://fastapi.tiangolo.com/) application served by [Uvicorn](https://www.uvicorn.org/). It reads energy metrics from `wattnet-storage` and zone/GeoJSON data from `wattnet-data`. The API is versioned; all current endpoints live under `/v1`. + +## Requirements + +- Python ≥ 3.10 +- Docker (for containerised deployment) +- A running [wattnet-storage](https://github.com/wattnet/wattnet-storage) backend + +## Installation + +### From PyPI + +Installs the `wattnet-api` server and its `wattnet-api` CLI entrypoint: + +```bash +pip install wattnet-api +``` + +### From source + +```bash +git clone --recurse-submodules https://github.com/wattnet/wattnet-api.git +cd wattnet-api +poetry install +``` + +### Docker + +Pre-built images are published to [GHCR](https://github.com/wattnet/wattnet-api/pkgs/container/wattnet-api) and [DockerHub](https://hub.docker.com/r/wattnet/wattnet-api) for `linux/amd64` and `linux/arm64`. Images are tagged by full version, minor, major, and `latest`. + +```bash +# Pull from GHCR +docker pull ghcr.io/wattnet/wattnet-api:latest + +# Or from DockerHub +docker pull wattnet/wattnet-api:latest +``` + +Run the container: + +```bash +docker run -p 8000:8000 --env-file config/.env.production ghcr.io/wattnet/wattnet-api:latest +``` + +## Configuration + +The server reads settings from environment variables or a `.env` file. Copy the example and adjust as needed: + +```bash +cp config/.env.example config/.env.development +``` + +| Variable | Default | Description | +| ------------------------ | ------------------------------------------------------- | ------------------------------------------------------- | +| `WATTNET_ENV` | `development` | Active environment; selects `config/.env.` | +| `API_HOST` | `localhost` | Bind address for the Uvicorn server | +| `API_PORT` | `8000` | Listening port | +| `API_DEBUG` | `True` | Enable debug mode and verbose logging | +| `GEOJSON_PATH` | _(bundled wattnet-data)_ | Directory with GeoJSON zone boundary files | +| `ZONES_FILE_PATH` | _(bundled wattnet-data)_ | Path to the zones YAML file | +| `CROSSBORDERS_FILE_PATH` | _(bundled wattnet-data)_ | Path to the crossborders YAML file | +| `LOG_LEVEL` | `INFO` | Logging level (`DEBUG`, `INFO`, `WARNING`, …) | +| `LOG_HANDLERS` | `["console"]` | Log outputs: `"console"` and/or `"file"` | +| `LOG_FILE` | `./logs/wattnet-api.log` | Log file path (only used when `file` handler is active) | +| `STORAGE_DB_URL` | `http://localhost:8123` | URL for the wattnet-storage ClickHouse backend | +| `ENTSOE_URL` | `https://web-api.tp.entsoe.eu/api` | ENTSO-E Transparency Platform API endpoint | +| `ELEXON_URL` | `https://data.elexon.co.uk/bmrs/api/v1` | ELEXON Balancing Mechanism Reporting Service endpoint | +| `EPIAS_URL` | `https://seffaflik.epias.com.tr/electricity-service/v1` | EPIAS Electricity Market Transparency Platform endpoint | + +## Running the API + +### With Poetry + +```bash +wattnet-api +``` + +Or directly with Uvicorn: + +```bash +uvicorn wattnet.api.app:versioned_app --host 0.0.0.0 --port 8000 --reload +``` + +### With Docker Compose + +```bash +docker compose up -d +``` + +## Interactive API Documentation + +Once the server is running, explore endpoints, parameters, and responses interactively: + +| Interface | URL | +| ------------ | ------------------------------------------------------------------------------ | +| Swagger UI | [http://localhost:8000/v1/docs](http://localhost:8000/v1/docs) | +| ReDoc | [http://localhost:8000/v1/redoc](http://localhost:8000/v1/redoc) | +| OpenAPI JSON | [http://localhost:8000/v1/openapi.json](http://localhost:8000/v1/openapi.json) | + +The production instance is available at **[https://api.wattnet.eu/docs](https://api.wattnet.eu/docs)**. + +> The API is versioned. The current version is `v1`; the base URL is `https://api.wattnet.eu/v1/`. + +### Health check + +The `/v1/status` endpoint reports whether the API and its upstream dependencies (wattnet-storage, ENTSO-E, ELEXON, EPIAS) are reachable. Useful for readiness probes in containerised deployments: + +```bash +curl http://localhost:8000/v1/status +``` + +## Contributing + +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for environment setup, code style, and how to run the tests. ## License @@ -30,9 +171,9 @@ This repository is licensed under the [Apache License 2.0](https://www.apache.or See the [LICENSE](LICENSE) file for more details. -## Funding and acknowledgments +## Funding and Acknowledgments -This work is funded by the European Union’s Horizon Europe research and innovation programme through the **[GreenDIGIT](https://greendigit-project.eu/)** project, under grant agreement **[101131207](https://cordis.europa.eu/project/id/101131207)**. +This work is funded by the European Union's Horizon Europe research and innovation programme through the **[GreenDIGIT](https://greendigit-project.eu/)** project, under grant agreement **[101131207](https://cordis.europa.eu/project/id/101131207)**.
EU Funded Logo diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..73e9a16 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,43 @@ +# Security Policy + +## Supported Versions + +We provide security fixes for the following versions: + +| Version | Supported | +|---------|-----------| +| 1.x | ✅ | + +Older versions do not receive security updates. Please upgrade to the latest release. + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +If you discover a vulnerability, report it privately by emailing: + +**iglesias@ifca.es** + +Include as much of the following as possible: + +- A description of the vulnerability and its potential impact. +- The affected version(s). +- Steps to reproduce or a proof-of-concept. +- Any suggested fix, if you have one. + +You will receive an acknowledgement within **3 business days**. We aim to provide a resolution timeline within **14 days** of the initial report. We will keep you informed throughout the process. + +## Disclosure Policy + +We follow a coordinated disclosure model: + +1. You report the vulnerability privately. +2. We confirm the issue and work on a fix. +3. We release the fix and publish a security advisory. +4. You may disclose publicly after the advisory is published, or after 90 days from the initial report — whichever comes first. + +We will credit reporters in the advisory unless you prefer to remain anonymous. + +## Scope + +This policy applies to the `wattnet-storage` library and the Docker Compose stack in this repository. Vulnerabilities in third-party dependencies (ClickHouse, Grafana, etc.) should be reported directly to those projects. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..1051636 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,16 @@ +coverage: + status: + project: + default: + target: auto + threshold: 1% + patch: + default: + target: auto + +comment: + layout: "reach,diff,flags,tree" + behavior: default + +ignore: + - "tests/" diff --git a/config/.env.example b/config/.env.example index a425c85..d7f75c7 100644 --- a/config/.env.example +++ b/config/.env.example @@ -1,24 +1,39 @@ -# Example .env file for wattnet REST API configuration +# Copy to .env (development) or /etc/wattnet/api.env (production) and fill in real values. +# Never commit files with real values. +# All keys here use the WATTNET_API_ prefix (required by pydantic-settings env_prefix). -## API Server Settings -API_HOST=localhost -API_PORT=8000 -API_DEBUG=True +# Server +WATTNET_API_HOST=0.0.0.0 +WATTNET_API_PORT=8000 +WATTNET_API_WORKERS=2 +WATTNET_API_DEBUG=false -## GeoJSON File Paths -GEOJSON_PATH=./data/geojson +# GeoJSON and zone file paths (optional — defaults to bundled data) +# WATTNET_API_GEOJSON_PATH=./data/geojson +# WATTNET_API_ZONES_FILE_PATH=/path/to/wattnet-data/zones/entsoe_selected_zones_2026.yaml +# WATTNET_API_CROSSBORDERS_FILE_PATH=/path/to/wattnet-data/zones/entsoe_selected_crossborders_2026.yaml -## Zones YAML File Paths (required) -ZONES_FILE_PATH=/path/to/wattnet-data/zones/entsoe_selected_zones_2026.yaml -CROSSBORDERS_FILE_PATH=/path/to/wattnet-data/zones/entsoe_selected_crossborders_2026.yaml +# Storage +WATTNET_API_TIMESERIES_STEP_MINUTES=15 +WATTNET_API_STORAGE_CLIENTS=["clickhouse"] -## Logging Settings -LOG_LEVEL=DEBUG -LOG_HANDLERS=["console", "file"] -LOG_FILE=/var/log/wattnet-api.log +# ClickHouse plugin — loaded by wattnet-api via pydantic-settings (CLICKHOUSE_ prefix) +# and injected into the storage library at startup via plugin_configs. +CLICKHOUSE_HOST=localhost +CLICKHOUSE_PORT=8123 +CLICKHOUSE_USER=default +CLICKHOUSE_PASSWORD= +CLICKHOUSE_DATABASE=wattnet +CLICKHOUSE_CONNECT_RETRIES=5 +CLICKHOUSE_CONNECT_RETRY_DELAY=3 -# Status Check Endpoint -STORAGE_DB_URL=http://localhost:8123 -ENTSOE_URL=https://web-api.tp.entsoe.eu/api -ELEXON_URL=https://data.elexon.co.uk/bmrs/api/v1 -EPIAS_URL=https://seffaflik.epias.com.tr/electricity-service/v1 +# External services +WATTNET_API_STORAGE_DB_URL=http://localhost:8123 +WATTNET_API_ENTSOE_URL=https://web-api.tp.entsoe.eu/api +WATTNET_API_ELEXON_URL=https://data.elexon.co.uk/bmrs/api/v1 +WATTNET_API_EPIAS_URL=https://seffaflik.epias.com.tr/electricity-service/v1 + +# Logging +WATTNET_API_LOG_LEVEL=INFO +WATTNET_API_LOG_HANDLERS=["console"] +WATTNET_API_LOG_FILE=./logs/wattnet-api.log diff --git a/data b/data new file mode 160000 index 0000000..a047b8d --- /dev/null +++ b/data @@ -0,0 +1 @@ +Subproject commit a047b8d0a4342395c92e64a2b4516333de44e8fb diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 567554c..0000000 --- a/mypy.ini +++ /dev/null @@ -1,15 +0,0 @@ -[mypy] -python_version = 3.13 -warn_unused_configs = True -warn_redundant_casts = True -warn_unused_ignores = True -warn_return_any = True -strict_optional = True - -namespace_packages = True -explicit_package_bases = True - -ignore_missing_imports = True - -[mypy-wattnet.*] -disallow_untyped_defs = True diff --git a/poetry.lock b/poetry.lock index 2c71a3e..b4ff235 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "annotated-doc" @@ -26,14 +26,14 @@ files = [ [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "test"] files = [ - {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, - {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, + {file = "anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9"}, + {file = "anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89"}, ] [package.dependencies] @@ -44,6 +44,49 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.32.0)"] +[[package]] +name = "ast-serialize" +version = "0.5.0" +description = "Python bindings for mypy AST serialization" +optional = false +python-versions = ">=3.7" +groups = ["types"] +files = [ + {file = "ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c"}, + {file = "ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb"}, + {file = "ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101"}, + {file = "ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43"}, + {file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27"}, + {file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d"}, + {file = "ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a"}, + {file = "ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590"}, + {file = "ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642"}, + {file = "ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6"}, +] + [[package]] name = "attrs" version = "26.1.0" @@ -75,14 +118,14 @@ testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-ch [[package]] name = "bandit" -version = "1.9.3" +version = "1.9.4" description = "Security oriented static analyser for python code." optional = false python-versions = ">=3.10" groups = ["security"] files = [ - {file = "bandit-1.9.3-py3-none-any.whl", hash = "sha256:4745917c88d2246def79748bde5e08b9d5e9b92f877863d43fab70cd8814ce6a"}, - {file = "bandit-1.9.3.tar.gz", hash = "sha256:ade4b9b7786f89ef6fc7344a52b34558caec5da74cb90373aed01de88472f774"}, + {file = "bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e"}, + {file = "bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628"}, ] [package.dependencies] @@ -100,39 +143,39 @@ yaml = ["PyYAML"] [[package]] name = "black" -version = "26.3.1" +version = "26.5.1" description = "The uncompromising code formatter." optional = false python-versions = ">=3.10" groups = ["format"] files = [ - {file = "black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2"}, - {file = "black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b"}, - {file = "black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac"}, - {file = "black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a"}, - {file = "black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a"}, - {file = "black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff"}, - {file = "black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c"}, - {file = "black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5"}, - {file = "black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e"}, - {file = "black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5"}, - {file = "black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1"}, - {file = "black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f"}, - {file = "black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7"}, - {file = "black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983"}, - {file = "black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb"}, - {file = "black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54"}, - {file = "black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f"}, - {file = "black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56"}, - {file = "black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839"}, - {file = "black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2"}, - {file = "black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78"}, - {file = "black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568"}, - {file = "black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f"}, - {file = "black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c"}, - {file = "black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1"}, - {file = "black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b"}, - {file = "black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07"}, + {file = "black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893"}, + {file = "black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90"}, + {file = "black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4"}, + {file = "black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef"}, + {file = "black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22"}, + {file = "black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c"}, + {file = "black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7"}, + {file = "black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59"}, + {file = "black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3"}, + {file = "black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe"}, + {file = "black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8"}, + {file = "black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217"}, + {file = "black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d"}, + {file = "black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264"}, + {file = "black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418"}, + {file = "black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3"}, + {file = "black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0"}, + {file = "black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294"}, + {file = "black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a"}, + {file = "black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52"}, + {file = "black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168"}, + {file = "black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3"}, + {file = "black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18"}, + {file = "black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50"}, + {file = "black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae"}, + {file = "black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2"}, + {file = "black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73"}, ] [package.dependencies] @@ -153,26 +196,26 @@ uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; [[package]] name = "cachetools" -version = "7.0.5" +version = "7.1.4" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114"}, - {file = "cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990"}, + {file = "cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54"}, + {file = "cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6"}, ] [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.5.20" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main", "release"] +groups = ["main", "release", "test"] files = [ - {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, - {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, + {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, + {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, ] [[package]] @@ -274,45 +317,15 @@ markers = {main = "implementation_name != \"pypy\" and os_name == \"nt\"", relea pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] -name = "chardet" -version = "7.4.0.post2" -description = "Universal character encoding detector" +name = "cfgv" +version = "3.5.0" +description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "chardet-7.4.0.post2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:77170d229f3d7babbc36c5a33c361de1c01091f4564a33bcd7e0f59ee8609b2a"}, - {file = "chardet-7.4.0.post2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9be8a6ba814f65013e0e6d92a43e8fa50f42c8850c143fa74586baeac5fa1bcd"}, - {file = "chardet-7.4.0.post2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28807a1209b7c2b79b24bdf9722b381e81da8104ae17fe2bd1e9f01c87fe9071"}, - {file = "chardet-7.4.0.post2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ade174e3fe29f1f4abdb3cc47add0a98201452c43786cbf324b5e237a0c79fc"}, - {file = "chardet-7.4.0.post2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:335d9cedd5b5be4b8b39ec25b1c2e4498ac4e8658c9466b68b4417cf07c8c4ee"}, - {file = "chardet-7.4.0.post2-cp310-cp310-win_amd64.whl", hash = "sha256:cde31d2314b156404380aca8aa0bdf6395bc92998b25336076b8a588c267fb20"}, - {file = "chardet-7.4.0.post2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90227bc83d06d16b548afe185e93eff8c740cb11ec51536366399b912e361b8d"}, - {file = "chardet-7.4.0.post2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:18cb15facd3a70042cb4d3b9a80dd2e9b8d78af90643f434047060e1f84dff06"}, - {file = "chardet-7.4.0.post2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e719bf17854051970938e260d2c589fe3fde3da0a681acdafd266e3bbf75c1af"}, - {file = "chardet-7.4.0.post2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24b8fcc1fe54936932f305522bc2f40a207ecbb38209fa24226eab7432531aef"}, - {file = "chardet-7.4.0.post2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c748b2850c8376ef04b02b3f22e014da5edc961478c88ccc6b01d3eed9bc1e7"}, - {file = "chardet-7.4.0.post2-cp311-cp311-win_amd64.whl", hash = "sha256:a359eb4535aeabd3f61e599530c4c4d4855c31316e6fed7db619a9c58785ee38"}, - {file = "chardet-7.4.0.post2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7aced16fe8098019c7c513dd92e9ee3ad29fffac757fa7de13ff8f3a8607a344"}, - {file = "chardet-7.4.0.post2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dc6829803ba71cb427dffac03a948ae828c617710bbd5f97ae3b34ab18558414"}, - {file = "chardet-7.4.0.post2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46659d38ba18e7c740f10a4c2edd0ef112e0322606ab2570cb8fd387954e0de9"}, - {file = "chardet-7.4.0.post2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5933289313b8cbfb0d07cf44583a2a6c7e31bffe5dcb7ebb6592825aa197d5b0"}, - {file = "chardet-7.4.0.post2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b99b417fac30641429829666ee7331366e797863504260aa1b18bfc2020e4e3"}, - {file = "chardet-7.4.0.post2-cp312-cp312-win_amd64.whl", hash = "sha256:a07dc1257fef2685dfc5182229abccd3f9b1299006a5b4d43ac7bd252faa1118"}, - {file = "chardet-7.4.0.post2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9bdb9387e692dd53c837aa922f676e5ab51209895cd99b15d30c6004418e0d27"}, - {file = "chardet-7.4.0.post2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:422ac637f5a2a8b13151245591cb0fabdf9ec1427725f0560628cb5ad4fb1462"}, - {file = "chardet-7.4.0.post2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d52b3f15249ba877030045900d179d44552c3c37dda487462be473ec67bed2f"}, - {file = "chardet-7.4.0.post2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccdfb13b4a727d3d944157c7f350c6d64630511a0ce39e37ffa5114e90f7d3a7"}, - {file = "chardet-7.4.0.post2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daae5b0579e7e33adacb4722a62b540e6bec49944e081a859cb9a6a010713817"}, - {file = "chardet-7.4.0.post2-cp313-cp313-win_amd64.whl", hash = "sha256:6c448fe2d77e329cec421b95f844b75f8c9cb744e808ecc9124b6063ca6acb5e"}, - {file = "chardet-7.4.0.post2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5862b17677f7e8fcee4e37fe641f01d30762e4b075ac37ce9584e4407896e2d9"}, - {file = "chardet-7.4.0.post2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:22d05c4b7e721d5330d99ef4a6f6233a9de58ae6f2275c21a098bedd778a6cb7"}, - {file = "chardet-7.4.0.post2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a035d407f762c21eb77069982425eb403e518dd758617aa43bf11d0d2203a1b6"}, - {file = "chardet-7.4.0.post2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2adfa7390e69cb5ed499b54978d31f6d476788d07d83da3426811181b7ca7682"}, - {file = "chardet-7.4.0.post2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2345f20ea67cdadddb778b2bc31e2defc2a85ae027931f9ad6ab84fd5d345320"}, - {file = "chardet-7.4.0.post2-cp314-cp314-win_amd64.whl", hash = "sha256:52602972d4815047cee262551bc383ab394aa145f5ca9ee10d0a53d27965882e"}, - {file = "chardet-7.4.0.post2-py3-none-any.whl", hash = "sha256:e0c9c6b5c296c0e5197bc8876fcc04d58a6ddfba18399e598ba353aba28b038e"}, - {file = "chardet-7.4.0.post2.tar.gz", hash = "sha256:21a6b5ca695252c03385dcfcc8b55c27907f1fe80838aa171b1ff4e356a1bb67"}, + {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, + {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, ] [[package]] @@ -456,14 +469,14 @@ files = [ [[package]] name = "click" -version = "8.3.2" +version = "8.4.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main", "format"] +groups = ["main", "deps", "format"] files = [ - {file = "click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"}, - {file = "click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5"}, + {file = "click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2"}, + {file = "click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"}, ] [package.dependencies] @@ -471,71 +484,72 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "clickhouse-connect" -version = "0.10.0" +version = "1.3.0" description = "ClickHouse Database Core Driver for Python, Pandas, and Superset" optional = false -python-versions = "<3.15,>=3.9" +python-versions = "<3.15,>=3.10" groups = ["main"] files = [ - {file = "clickhouse_connect-0.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6db414cd78333c5430e95d21c75968ad5416a37662fb7ef5536ddae1e46283ee"}, - {file = "clickhouse_connect-0.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f50fe43ddd9161986cc881ce2276d665d99c3d77f5d595c9e9497f9f10e0270b"}, - {file = "clickhouse_connect-0.10.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01e5ba7696789b445392816180910a6bc9b0995cb86f3d503179e2be13991919"}, - {file = "clickhouse_connect-0.10.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d70432f1dfb88f49d7d95f62c51d762cf1fb5867e7e52aeab1f97f1bebf678e"}, - {file = "clickhouse_connect-0.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9c30c902da7eb01d60b61b566603ab2069e0813b8db60b7c75a4be34b62f63e8"}, - {file = "clickhouse_connect-0.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:db8452ef4efe1948c180a7becb572fb4926dfc69f9f5cdd29e70841b7e97e8dd"}, - {file = "clickhouse_connect-0.10.0-cp310-cp310-win32.whl", hash = "sha256:71cafb1918ec41dd46d6ec943a1d8caa3bf1f9a59c5b3d73d2dfda065d4834b7"}, - {file = "clickhouse_connect-0.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:7e0d9ad118a398c269b45591077d496ee5472cf78f4e334a709e9e2aa064eedf"}, - {file = "clickhouse_connect-0.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:195f1824405501b747b572e1365c6265bb1629eeb712ce91eda91da3c5794879"}, - {file = "clickhouse_connect-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7907624635fe7f28e1b85c7c8b125a72679a63ecdb0b9f4250b704106ef438f8"}, - {file = "clickhouse_connect-0.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60772faa54d56f0fa34650460910752a583f5948f44dddeabfafaecbca21fc54"}, - {file = "clickhouse_connect-0.10.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fe2a6cd98517330c66afe703fb242c0d3aa2c91f2f7dc9fb97c122c5c60c34b"}, - {file = "clickhouse_connect-0.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a2427d312bc3526520a0be8c648479af3f6353da7a33a62db2368d6203b08efd"}, - {file = "clickhouse_connect-0.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63bbb5721bfece698e155c01b8fa95ce4377c584f4d04b43f383824e8a8fa129"}, - {file = "clickhouse_connect-0.10.0-cp311-cp311-win32.whl", hash = "sha256:48554e836c6b56fe0854d9a9f565569010583d4960094d60b68a53f9f83042f0"}, - {file = "clickhouse_connect-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:9eb8df083e5fda78ac7249938691c2c369e8578b5df34c709467147e8289f1d9"}, - {file = "clickhouse_connect-0.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b090c7d8e602dd084b2795265cd30610461752284763d9ad93a5d619a0e0ff21"}, - {file = "clickhouse_connect-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b8a708d38b81dcc8c13bb85549c904817e304d2b7f461246fed2945524b7a31b"}, - {file = "clickhouse_connect-0.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3646fc9184a5469b95cf4a0846e6954e6e9e85666f030a5d2acae58fa8afb37e"}, - {file = "clickhouse_connect-0.10.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe7e6be0f40a8a77a90482944f5cc2aa39084c1570899e8d2d1191f62460365b"}, - {file = "clickhouse_connect-0.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:88b4890f13163e163bf6fa61f3a013bb974c95676853b7a4e63061faf33911ac"}, - {file = "clickhouse_connect-0.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6286832cc79affc6fddfbf5563075effa65f80e7cd1481cf2b771ce317c67d08"}, - {file = "clickhouse_connect-0.10.0-cp312-cp312-win32.whl", hash = "sha256:92b8b6691a92d2613ee35f5759317bd4be7ba66d39bf81c4deed620feb388ca6"}, - {file = "clickhouse_connect-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:1159ee2c33e7eca40b53dda917a8b6a2ed889cb4c54f3d83b303b31ddb4f351d"}, - {file = "clickhouse_connect-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f927722c5e054cf833a4112cf82d633e37d3b329f01e232754cc2678be268020"}, - {file = "clickhouse_connect-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef58f431e2ef3c2a91a6d5535484186f2f57f50eff791410548b17017563784b"}, - {file = "clickhouse_connect-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40b7cf86d016ae6c6c3af6a7b5786f41c18632bfbc9e58d0c4a21a4c5d50c674"}, - {file = "clickhouse_connect-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51193dc39f4169b0dd6da13003bbea60527dea92eb2408aecae7f1fb4ad2c5a4"}, - {file = "clickhouse_connect-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b3e393dd95bcce02307f558f6aee53bf2a1bfc83f13030c9b4e47b2045de293f"}, - {file = "clickhouse_connect-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bd6e1870df82dd57a47bc2a2a6f39c57da8aee43cc291a44d04babfdec5986dc"}, - {file = "clickhouse_connect-0.10.0-cp313-cp313-win32.whl", hash = "sha256:d69b3f55a3a2f5414db7bed45afcca940e78ce1867cf5cc0c202f7be21cf48e9"}, - {file = "clickhouse_connect-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:5fa4f3763d46b90dc28b1f38eba8de83fbf6c9928f071dd66074e7d6de80e21b"}, - {file = "clickhouse_connect-0.10.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a4f20ea756e0c019e06a51d23f41edf1f0c260615e0572cb7ab0f696dfec91c"}, - {file = "clickhouse_connect-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7fbdba6b414d52e21cccb23545e3562873318a898247e9b7108aec019911f1b4"}, - {file = "clickhouse_connect-0.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19cb3af95721013a0f8e88276277e23e960b08f7c14613a325a14c418207f54f"}, - {file = "clickhouse_connect-0.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1246137a53fb270d4bb8b51e56816d5b3f5cc595a5b2d281393308a34d8a5f43"}, - {file = "clickhouse_connect-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5b20b3f8f93743f4dcc61dc2bd9e5c374de1e57d4a601f48e46dd06d2d4f7b97"}, - {file = "clickhouse_connect-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e32ef05046558928728d577ff6e053495cb5bf870e1f61fd2ea0c980587fefb7"}, - {file = "clickhouse_connect-0.10.0-cp314-cp314-win32.whl", hash = "sha256:28f2666e59bf478461693e10e84acaa9a7e32b427d2d3d72843fd7e0a7415a77"}, - {file = "clickhouse_connect-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:93bf4869d27d9e86469f8fa4f0f27a618e4e63a970c3084f531c0d4706efba49"}, - {file = "clickhouse_connect-0.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a545a9a1ebbd8489bf81dfad43ae877ce54d51ed88b635a35df9f4ea42eba6a4"}, - {file = "clickhouse_connect-0.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f798b9941490e9d6aa1b86c6f06a602d0568cc12c0589c8cfc406fb871f42062"}, - {file = "clickhouse_connect-0.10.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d9b815ec685e143ba22fb6b6803a397da2daacccaa700ced998633ff0ef5e24"}, - {file = "clickhouse_connect-0.10.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4cf7a2e62874f173b34c593941da1d7472c9db6ffdd6de0123ecc3cfecf6b8d"}, - {file = "clickhouse_connect-0.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7c72d7a0564fe8e3c393ad89f19cfdc31cd7bd8b2abd9ff1a4ea66a034180a70"}, - {file = "clickhouse_connect-0.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:75a91c5c29d1afad1f925037747200c2a57106665dc40234bfd5e92436588874"}, - {file = "clickhouse_connect-0.10.0-cp39-cp39-win32.whl", hash = "sha256:1405057ae1b6225e2de7879f582afcf7049d2cde858d0bda32b615d5f82ed330"}, - {file = "clickhouse_connect-0.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:a22457d56570eea77618e30e2a250484a7d70594dc10d636b4d5a454bb405e9a"}, - {file = "clickhouse_connect-0.10.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:185975081de4dbec4096210f0c5adf1cf89e4c03e92f5eab1afbb70cf0636c14"}, - {file = "clickhouse_connect-0.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aacaff01523192fd319f60440908b67ca5e26c762a74a00a7c32f9913fe59e12"}, - {file = "clickhouse_connect-0.10.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:225d052bd5b885e43dd13b3a3bb251f76fcdd429b160558d2abb50ebe958f921"}, - {file = "clickhouse_connect-0.10.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c755df1791c779b3a0a54e0789f6f55cbedfc6d6aa49046223e62986886b90d"}, - {file = "clickhouse_connect-0.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:57239e8f49fc31d5993cb6b3bc14c00f2704d6a4a73c96ad97496c6c00144da5"}, - {file = "clickhouse_connect-0.10.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42a5101decf2d9b49cf95619486e9f4d192e08d05886c513001f6238a21f4c70"}, - {file = "clickhouse_connect-0.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:0b3bbb1efdb3d71b6a2a2dcd607b0899f3b1ffe1e8125662709ee2ebbc1503cc"}, - {file = "clickhouse_connect-0.10.0-pp39-pypy39_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e9de32b9a9f3c39caf5c8837eb07512fa4e8de7a182bcdbb82f2ae551d7651"}, - {file = "clickhouse_connect-0.10.0-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0afc1b2fef342f4b077c66fb8bf87bbe7ec74547940357239d35c249d45f983"}, - {file = "clickhouse_connect-0.10.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21e9fe9fbca37724898ff15e29c5332682786e0b95ba0c15b5f3a9c628c83873"}, - {file = "clickhouse_connect-0.10.0.tar.gz", hash = "sha256:a0256328802c6e5580513e197cef7f9ba49a99fc98e9ba410922873427569564"}, + {file = "clickhouse_connect-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:68b3183641b4249081d66507bce336cd82262a31c80eef2df2c1e7c45902a8fb"}, + {file = "clickhouse_connect-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d7816bada44c2bfb9be6e9d86c0a32029d8832c28125cea8cdc9c118d3c8b6c"}, + {file = "clickhouse_connect-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed34ac11cd4dfd3c9d9ea6ce8916fd7b37e4efdb2b67ab2e550240ae9b69c630"}, + {file = "clickhouse_connect-1.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17cd08c84f7c24f79bb3543a8d231c504ee98af1e1b66a939ac5a48cb759dd89"}, + {file = "clickhouse_connect-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:892db69bc3f3b51445cde66441e8a8c5f11b333a7690d070a35541029c4d5b5c"}, + {file = "clickhouse_connect-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f3b44d2147ba0c6a892b977fe47e9add91b6080cf865381e4551add7a1756e8e"}, + {file = "clickhouse_connect-1.3.0-cp310-cp310-win32.whl", hash = "sha256:a76f4af7a9822b2464219d1c168ae335874233ca6e062e959c9d72fce6c7f963"}, + {file = "clickhouse_connect-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:150ce2329e821652fd7875c013dc00837ac6e1892f0a40403b5b25a4e8df60bf"}, + {file = "clickhouse_connect-1.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:b5b0c03fcd363bc5c0e2104c5a7a7401c17f5d272a5d18cdc1288b99eb1abca2"}, + {file = "clickhouse_connect-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc5bc6e5b662fe854c193177df4bc281bbcee5b1a01a33be80cdc346d398fdbb"}, + {file = "clickhouse_connect-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:80e7eff3774a370f14ae0155d2772732c126ba58ad6fa5e4a09097e6c03fbb52"}, + {file = "clickhouse_connect-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:358418fb63ba4320ff3e19113e8000854617ef7e31158e92ee6d96539023a88d"}, + {file = "clickhouse_connect-1.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1dedea8cc2a6edf89e6b6c6cc7d81c89bbc844560b384d6242f0f0591c4b8df"}, + {file = "clickhouse_connect-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:84bd4a8cdf697b4cc3db884d26d9b991d51b76758fa9a12c0842bded1ae69487"}, + {file = "clickhouse_connect-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:735482344b93275ddc81123d67f8bd30fa5c614d48ebce86bd47824834bcff83"}, + {file = "clickhouse_connect-1.3.0-cp311-cp311-win32.whl", hash = "sha256:532d0ad2994c5f0f225ce04f14bcbf53530901125c694081c8f9881b208f36db"}, + {file = "clickhouse_connect-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7340ec456ae9785f18c82170d00f84a23eb8919ad9595cf8f816da729f3031fa"}, + {file = "clickhouse_connect-1.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:da6435d0618fcbdc0734347a48c144547196233eb60bc8fb4e3ad4b7d37deb1b"}, + {file = "clickhouse_connect-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df2ca5489ad9972503700d45b1d8d4e8c64ae3bcb5205c9ae43e7aa37446ccf3"}, + {file = "clickhouse_connect-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:32a94f9b682f9bfaf6cc8f1608ac0b9c3184361fdd7354f5adb8c9e1f8256f51"}, + {file = "clickhouse_connect-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9a47a44fd6bb9f60e103903c3712f2732724f472130092693bc05cda2654f74"}, + {file = "clickhouse_connect-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86cf31110bfc8c9f3941a23229b906fc3e54ef88751c7b14b1afdb20871e75d2"}, + {file = "clickhouse_connect-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f9afc060d8f001f05097a16ce05baf52900ec575a07049d9a16966f412a273f8"}, + {file = "clickhouse_connect-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b9d973fbc29f5d1ec3a41c58c8a3a28d191e17bbfa243ac7b588786d4f21abcd"}, + {file = "clickhouse_connect-1.3.0-cp312-cp312-win32.whl", hash = "sha256:f0c5c92497909b299c4c48e767943fe5b86c90101ade768bf9f0428fb5078954"}, + {file = "clickhouse_connect-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:54f3fad4447df2bf023280b61db0bbe85762f3c6a4a38a9f8f76ffaf6d6f7cbe"}, + {file = "clickhouse_connect-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:fbb19aba0d474576ae594b55aeb969f594d3718cae6f20a3f08da25daca6bebc"}, + {file = "clickhouse_connect-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ac62f270da013a6bb224a135fbebb8cefbc7137371d9fca9c3a58b55daa7b023"}, + {file = "clickhouse_connect-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9164b2d021eb4f8474767a301e196ffdb045ee960e9b68fc00c53b975ea10e79"}, + {file = "clickhouse_connect-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2432a22aab181a483f3dc6b398c8f641099f2d71bb9289ec84de9643a3b96493"}, + {file = "clickhouse_connect-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee1b1d91258859260b7a2059fea87e1ee52f006e4a99a76ef21aa67a31face5"}, + {file = "clickhouse_connect-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:69c6f9b3f2fb766ff87fa0c432c27c67b5b7de643ace33f4c4635ce1722d86ad"}, + {file = "clickhouse_connect-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9022aefacb10ab9cb56944e24322736a3917a40527e1b3853c33b9497ea06a7f"}, + {file = "clickhouse_connect-1.3.0-cp313-cp313-win32.whl", hash = "sha256:900e4bd920b34a8ce3c21616dc55da869e4cfdae900e80fc00cfab2ae0f571e1"}, + {file = "clickhouse_connect-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:5a294365cda07869ea30ef00ea07b542e50cc77ca7f329ebf3e1bcdce5b0afb8"}, + {file = "clickhouse_connect-1.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:219230675f255626340674d8a393ac0abf68810c2269e9c73afb35673facea7e"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d1645af60860edb2f61b474daf5aa0af6b48724eb4d2c748edfa4027fdbe58a"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:225de4ab00609e599f2529a8a5256da5f473ce9544a04ab9b18b8fdd5baf9005"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c18c0773a85f26c7eaeb59e0fd0a142e464312fda1c54fc7feae6115eb1759d4"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61f2f32403ac23354a572b160fa0a51ad5e76ba88aea37ebe5371b8863659339"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e655403a35d016ac2100ca49fece73d1f293c6623809a04e530aa05c0369d69c"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bf99dc475626d1fc1720c3cbc8bd6154d26f829cf272c3c2524e5b7ce132fca1"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314-win32.whl", hash = "sha256:3954d59ced274163e1243549994eeaa6ae7fb46c1635ebd661eefefd25ce7c3d"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:6447aaa3cda9a01580bffd821519199901124e979673a76f8c00d353293b2bd2"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:059f0ef645734cd5425b6af3bdac87c57f93e9c0dd1c86ef01da68846e2dd949"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:331d2aaadd1ef7fe238209da1d6bdbcb9244da6bb94f581beed8193d6b04ac2d"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ab4380888830b92878231415fabab0b1b90bb3982d599e8444f8f9aa22174249"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a63c5f646142679b9288cf9ab1cc6af028389c894a67efa372f9cf60f2d97264"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7139165a4beccf7604c66849cd5da1cc2be97de76e39ca03fb93c34dc9a4c570"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:73b089407354a0a2903561a5c86c2cb359705d38e9701dbef5fe13bbf69d94fd"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9bce3cd4af89d6a0271938f1fabfc10a8889e1a7c4dbc4a866d2a804c4ed506"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:6c26e0175dddc2e3eafb606781c0a10be4fee13831117375f645eb70db722319"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:016cdfe4c17d898044f618963d11cc9604e98b239e269b5e69dcabcf2625ace9"}, + {file = "clickhouse_connect-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f9341e3072d353bb834300c2a27e88a6fcde7a6c259c27c99fb530b97fcbc788"}, + {file = "clickhouse_connect-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1f32d29a9b620ce911101c08c422ab96d09ece02f1237fe583e26dbb20fda323"}, + {file = "clickhouse_connect-1.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61240b9a722aae5341dc3bdc1931cb39c4769da549cadeebac58245c0d1616b8"}, + {file = "clickhouse_connect-1.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9a48762ccff33cb1583f0e77eb254f19d3fbaece4db25286432ec602cee33c7"}, + {file = "clickhouse_connect-1.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a8f2158400e8e7603ff011bdcc4273eae3fd47c78e4b2a359798cb32a3540b8"}, + {file = "clickhouse_connect-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ca830ec1c574d1c184e551ab39524a17bb80f20727028276f47c0828013644df"}, + {file = "clickhouse_connect-1.3.0.tar.gz", hash = "sha256:32e780ff3de62dbff2ff21eaf0501582b5365fba6c42227e203664379312e33e"}, ] [package.dependencies] @@ -544,7 +558,8 @@ lz4 = [ {version = "*", markers = "python_version < \"3.14\""}, {version = ">=4.4.5", markers = "python_version >= \"3.14\""}, ] -pytz = "*" +pandas = {version = ">=2,<4", optional = true, markers = "extra == \"pandas\""} +tzdata = {version = "*", markers = "sys_platform == \"win32\""} urllib3 = ">=1.26" zstandard = [ {version = "*", markers = "python_version < \"3.14\""}, @@ -552,12 +567,15 @@ zstandard = [ ] [package.extras] +alembic = ["alembic (>=1.16)", "sqlalchemy (>=1.4.40,<3.0)"] arrow = ["pyarrow (>=22.0) ; python_version >= \"3.14\"", "pyarrow ; python_version < \"3.14\""] +async = ["aiohttp (>=3.9.0)"] numpy = ["numpy"] orjson = ["orjson"] -pandas = ["pandas"] +pandas = ["pandas (>=2,<4)"] polars = ["polars (>=1.0)"] sqlalchemy = ["sqlalchemy (>=1.4.40,<3.0)"] +tzdata = ["tzdata"] tzlocal = ["tzlocal (>=4.0)"] [[package]] @@ -566,127 +584,127 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev", "format", "security", "test"] +groups = ["main", "deps", "dev", "format", "security", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\"", format = "platform_system == \"Windows\"", security = "platform_system == \"Windows\"", test = "sys_platform == \"win32\""} +markers = {main = "platform_system == \"Windows\"", deps = "platform_system == \"Windows\" or sys_platform == \"win32\"", format = "platform_system == \"Windows\"", security = "platform_system == \"Windows\"", test = "sys_platform == \"win32\""} [[package]] name = "coverage" -version = "7.13.5" +version = "7.14.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, - {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"}, - {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"}, - {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"}, - {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"}, - {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"}, - {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"}, - {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"}, - {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"}, - {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"}, - {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"}, - {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"}, - {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"}, - {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"}, - {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"}, - {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"}, - {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"}, - {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"}, - {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"}, - {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"}, - {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"}, - {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"}, - {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"}, - {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"}, - {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"}, - {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"}, - {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"}, - {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"}, - {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"}, - {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"}, - {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"}, - {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"}, - {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"}, - {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"}, - {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"}, - {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"}, - {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"}, - {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"}, - {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"}, - {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"}, - {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"}, - {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"}, - {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"}, - {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"}, - {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"}, - {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"}, - {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"}, - {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"}, - {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"}, - {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"}, + {file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"}, + {file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"}, + {file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"}, + {file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"}, + {file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"}, + {file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"}, + {file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"}, + {file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"}, + {file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"}, + {file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"}, + {file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"}, + {file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"}, + {file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"}, + {file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"}, + {file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"}, + {file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"}, + {file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"}, + {file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"}, + {file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"}, + {file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"}, + {file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"}, + {file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"}, + {file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"}, + {file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"}, + {file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"}, + {file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"}, + {file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"}, + {file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"}, + {file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"}, + {file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"}, + {file = "coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b"}, + {file = "coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474"}, + {file = "coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86"}, + {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e"}, + {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65"}, + {file = "coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e"}, + {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8"}, + {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07"}, + {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de"}, + {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890"}, + {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd"}, + {file = "coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e"}, + {file = "coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c"}, + {file = "coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af"}, + {file = "coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2"}, + {file = "coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be"}, ] [package.dependencies] @@ -697,100 +715,123 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "46.0.6" +version = "49.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.8" +python-versions = "!=3.9.0,!=3.9.1,>=3.9" groups = ["release"] markers = "sys_platform == \"linux\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ - {file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"}, - {file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"}, - {file = "cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a"}, - {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175"}, - {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463"}, - {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97"}, - {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c"}, - {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507"}, - {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19"}, - {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738"}, - {file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c"}, - {file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f"}, - {file = "cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2"}, - {file = "cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124"}, - {file = "cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275"}, - {file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4"}, - {file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b"}, - {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707"}, - {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361"}, - {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b"}, - {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca"}, - {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013"}, - {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4"}, - {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a"}, - {file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d"}, - {file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736"}, - {file = "cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed"}, - {file = "cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4"}, - {file = "cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a"}, - {file = "cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8"}, - {file = "cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77"}, - {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290"}, - {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410"}, - {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d"}, - {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70"}, - {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d"}, - {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa"}, - {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58"}, - {file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb"}, - {file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72"}, - {file = "cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c"}, - {file = "cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f"}, - {file = "cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead"}, - {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8"}, - {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0"}, - {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b"}, - {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a"}, - {file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"}, - {file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"}, + {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, + {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, + {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, + {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, + {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, + {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, + {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, ] [package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] + +[[package]] +name = "deptry" +version = "0.25.1" +description = "A command line utility to check for unused, missing and transitive dependencies in a Python project." +optional = false +python-versions = ">=3.10" +groups = ["deps"] +files = [ + {file = "deptry-0.25.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a4dd1148db24a1ddacfa8b840836c6019c2f864fcb7579dd089fd217606338c8"}, + {file = "deptry-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c67c666d916ef12013c0772e40d78be0f21577a495d8d99ec5fcb18c332d393d"}, + {file = "deptry-0.25.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d39279828dbf4efc1abb40bf50a71b21499c36759bed5a8d8a3c0e3149b091"}, + {file = "deptry-0.25.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14bfcc28b4326ed8c6abb30691b19077d4ef8613cfba6c37ef5b1f471775bf6f"}, + {file = "deptry-0.25.1-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:555f5f9a487899ec9bf301eecba1745e14d212c4b354f4d3a5fd691e907366d3"}, + {file = "deptry-0.25.1-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d21b3545ab2bfec53f3f45c6f5f201d55f713323327f8d12674505469ae6b7"}, + {file = "deptry-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:b59a560cb7dffb21832a98bb80d33d614cfb5630ea36ce21833eabf4eae3df99"}, + {file = "deptry-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:6efffd8116fb9d2c45a251382ce4ce1c38dbb17179f581ec9231ed5390f7fc12"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:30d64d4df1c08bc69de56cb0b4ec1f4cd9fa2e42582347d5b1eb25fd0e401745"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:87bcd90f99a98bb059c7580bc315c3f87d97fe2db725530030bc974176834735"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80f31eb5c520651b102568dd91f738222b250a3e44c9e95d4941322109b8d40a"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df88952a2bab7517ef23cb304b979199b28449e5d9db2e9ba9bc27a286ac852b"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e6f7b8fa72932e51e86799b10dcd29381b2132dc799c790dca3b28ab08dffb28"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e3fa3321078e11cd1ac3f10ce3ff0547731c53f9253b87c757a8749c76fe8fa9"}, + {file = "deptry-0.25.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:03c032c32492fde434736954fbcaff09c02bf207b0f793b77e9040300e34b344"}, + {file = "deptry-0.25.1.tar.gz", hash = "sha256:45c8cd982c85cd4faae573ddff6920de7eec735336db6973f26a765ae7950f7d"}, +] + +[package.dependencies] +click = ">=8.0.0,<9" +colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} +packaging = ">=23.2" +requirements-parser = ">=0.11.0,<1" +tomli = {version = ">=2.0.1", markers = "python_full_version < \"3.15.0\""} [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.3" description = "Distribution utilities" optional = false python-versions = "*" groups = ["dev"] files = [ - {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, - {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, + {file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"}, + {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, ] [[package]] name = "docutils" -version = "0.22.4" +version = "0.23" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" groups = ["release"] files = [ - {file = "docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de"}, - {file = "docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968"}, + {file = "docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea"}, + {file = "docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e"}, ] [[package]] @@ -853,14 +894,14 @@ starlette = "*" [[package]] name = "filelock" -version = "3.25.2" +version = "3.29.4" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, - {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, + {file = "filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767"}, + {file = "filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a"}, ] [[package]] @@ -975,12 +1016,59 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "test"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "id" version = "1.6.1" @@ -1001,20 +1089,35 @@ dev = ["build", "bump (>=1.3.2)", "id[lint,test]"] lint = ["bandit", "interrogate", "mypy", "ruff (<0.14.15)"] test = ["coverage[toml]", "pretend", "pytest", "pytest-cov"] +[[package]] +name = "identify" +version = "2.6.19" +description = "File identification library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a"}, + {file = "identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" -version = "3.11" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "release", "test"] files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "importlib-metadata" @@ -1053,6 +1156,21 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "isort" +version = "8.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.10.0" +groups = ["format"] +files = [ + {file = "isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75"}, + {file = "isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d"}, +] + +[package.extras] +colors = ["colorama"] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1099,27 +1217,27 @@ type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] [[package]] name = "jaraco-functools" -version = "4.4.0" +version = "4.5.0" description = "Functools like those found in stdlib" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["release"] markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ - {file = "jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176"}, - {file = "jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb"}, + {file = "jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4"}, + {file = "jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03"}, ] [package.dependencies] more_itertools = "*" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=3.4)"] test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] -type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] [[package]] name = "jeepney" @@ -1171,103 +1289,103 @@ type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] [[package]] name = "librt" -version = "0.8.1" +version = "0.11.0" description = "Mypyc runtime library" optional = false python-versions = ">=3.9" groups = ["types"] markers = "platform_python_implementation != \"PyPy\"" files = [ - {file = "librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc"}, - {file = "librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7"}, - {file = "librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6"}, - {file = "librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0"}, - {file = "librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b"}, - {file = "librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05"}, - {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891"}, - {file = "librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7"}, - {file = "librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2"}, - {file = "librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd"}, - {file = "librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965"}, - {file = "librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da"}, - {file = "librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0"}, - {file = "librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e"}, - {file = "librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99"}, - {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe"}, - {file = "librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb"}, - {file = "librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b"}, - {file = "librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9"}, - {file = "librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a"}, - {file = "librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9"}, - {file = "librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb"}, - {file = "librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d"}, - {file = "librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7"}, - {file = "librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921"}, - {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0"}, - {file = "librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a"}, - {file = "librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444"}, - {file = "librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d"}, - {file = "librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35"}, - {file = "librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583"}, - {file = "librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c"}, - {file = "librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04"}, - {file = "librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363"}, - {file = "librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b"}, - {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d"}, - {file = "librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a"}, - {file = "librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79"}, - {file = "librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0"}, - {file = "librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f"}, - {file = "librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c"}, - {file = "librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc"}, - {file = "librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c"}, - {file = "librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3"}, - {file = "librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071"}, - {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78"}, - {file = "librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023"}, - {file = "librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730"}, - {file = "librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3"}, - {file = "librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1"}, - {file = "librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e"}, - {file = "librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382"}, - {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994"}, - {file = "librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a"}, - {file = "librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4"}, - {file = "librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61"}, - {file = "librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac"}, - {file = "librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed"}, - {file = "librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd"}, - {file = "librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851"}, - {file = "librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128"}, - {file = "librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6"}, - {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed"}, - {file = "librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc"}, - {file = "librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7"}, - {file = "librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73"}, + {file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"}, + {file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"}, + {file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"}, + {file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"}, + {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"}, + {file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"}, + {file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"}, + {file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"}, + {file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"}, + {file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"}, + {file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"}, + {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"}, + {file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"}, + {file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"}, + {file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"}, + {file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"}, + {file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"}, + {file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"}, + {file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"}, + {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"}, + {file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"}, + {file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"}, + {file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"}, + {file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"}, + {file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"}, + {file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"}, + {file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"}, + {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"}, + {file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"}, + {file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"}, + {file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"}, + {file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"}, + {file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"}, + {file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"}, + {file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"}, + {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"}, + {file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"}, + {file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"}, + {file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"}, + {file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"}, + {file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"}, + {file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"}, + {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"}, + {file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"}, + {file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"}, + {file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"}, + {file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"}, + {file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"}, + {file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"}, + {file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"}, + {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"}, + {file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"}, + {file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"}, + {file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"}, ] [[package]] @@ -1344,14 +1462,14 @@ tests = ["psutil", "pytest (!=3.3.0)", "pytest-cov"] [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" groups = ["release", "security"] files = [ - {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, - {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, + {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, + {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, ] [package.dependencies] @@ -1364,7 +1482,7 @@ linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins (>=0.5.0)"] profiling = ["gprof2dot"] rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] [[package]] name = "mccabe" @@ -1392,71 +1510,78 @@ files = [ [[package]] name = "more-itertools" -version = "11.0.1" +version = "11.1.0" description = "More routines for operating on iterables, beyond itertools" optional = false python-versions = ">=3.10" groups = ["release"] markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ - {file = "more_itertools-11.0.1-py3-none-any.whl", hash = "sha256:eaf287826069452a8f61026c597eae2428b2d1ba2859083abbf240b46842ce6d"}, - {file = "more_itertools-11.0.1.tar.gz", hash = "sha256:fefaf25b7ab08f0b45fa9f1892cae93b9fc0089ef034d39213bce15f1cc9e199"}, + {file = "more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192"}, + {file = "more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d"}, ] [[package]] name = "mypy" -version = "1.19.1" +version = "2.1.0" description = "Optional static typing for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["types"] files = [ - {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, - {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, - {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, - {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, - {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, - {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, - {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, - {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, - {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, - {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, - {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, - {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, - {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, - {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, - {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, - {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, - {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, - {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, - {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, - {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, - {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, - {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, - {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, - {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, - {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, - {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, - {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, - {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, - {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, - {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, - {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, - {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, - {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, - {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, - {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, - {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, - {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, - {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, + {file = "mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc"}, + {file = "mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849"}, + {file = "mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd"}, + {file = "mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166"}, + {file = "mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8"}, + {file = "mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8"}, + {file = "mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e"}, + {file = "mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41"}, + {file = "mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca"}, + {file = "mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538"}, + {file = "mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398"}, + {file = "mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563"}, + {file = "mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389"}, + {file = "mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666"}, + {file = "mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af"}, + {file = "mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6"}, + {file = "mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211"}, + {file = "mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b"}, + {file = "mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22"}, + {file = "mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b"}, + {file = "mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8"}, + {file = "mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5"}, + {file = "mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e"}, + {file = "mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e"}, + {file = "mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285"}, + {file = "mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5"}, + {file = "mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65"}, + {file = "mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d"}, + {file = "mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2"}, + {file = "mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f"}, + {file = "mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4"}, + {file = "mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef"}, + {file = "mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135"}, + {file = "mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21"}, + {file = "mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57"}, + {file = "mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e"}, + {file = "mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780"}, + {file = "mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd"}, + {file = "mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08"}, + {file = "mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081"}, + {file = "mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7"}, + {file = "mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6"}, + {file = "mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289"}, + {file = "mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633"}, ] [package.dependencies] -librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} +ast-serialize = ">=0.3.0,<1.0.0" +librt = {version = ">=0.11.0", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" -pathspec = ">=0.9.0" +pathspec = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing_extensions = ">=4.6.0" +typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.15\""} [package.extras] dmypy = ["psutil (>=4.0)"] @@ -1479,39 +1604,51 @@ files = [ [[package]] name = "nh3" -version = "0.3.4" +version = "0.3.5" description = "Python binding to Ammonia HTML sanitizer Rust crate" optional = false python-versions = ">=3.8" groups = ["release"] files = [ - {file = "nh3-0.3.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8b61058f34c2105d44d2a4d4241bacf603a1ef5c143b08766bbd0cf23830118f"}, - {file = "nh3-0.3.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:554cc2bab281758e94d770c3fb0bf2d8be5fb403ef6b2e8841dd7c1615df7a0f"}, - {file = "nh3-0.3.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dbe76feaa44e2ef9436f345016012a591550e77818876a8de5c8bc2a248e08df"}, - {file = "nh3-0.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87dac8d611b4a478400e0821a13b35770e88c266582f065e7249d6a37b0f86e8"}, - {file = "nh3-0.3.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8d697e19f2995b337f648204848ac3a528eaafffc39e7ce4ac6b7a2fbe6c84af"}, - {file = "nh3-0.3.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:7cae217f031809321db962cd7e092bda8d4e95a87f78c0226628fa6c2ea8ebc5"}, - {file = "nh3-0.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:07999b998bf89692738f15c0eac76a416382932f855709e0b7488b595c30ec89"}, - {file = "nh3-0.3.4-cp314-cp314t-win32.whl", hash = "sha256:ca90397c8d36c1535bf1988b2bed006597337843a164c7ec269dc8813f37536b"}, - {file = "nh3-0.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:41e46b3499918ab6128b6421677b316e79869d0c140da24069d220a94f4e72d1"}, - {file = "nh3-0.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:80b955d802bf365bd42e09f6c3d64567dce777d20e97968d94b3e9d9e99b265e"}, - {file = "nh3-0.3.4-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d8bebcb20ab4b91858385cd98fe58046ec4a624275b45ef9b976475604f45b49"}, - {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d825722a1e8cbc87d7ca1e47ffb1d2a6cf343ad4c1b8465becf7cadcabcdfd0"}, - {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4aa8b43e68c26b68069a3b6cef09de166d1d7fa140cf8d77e409a46cbf742e44"}, - {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f5f214618ad5eff4f2a6b13a8d4da4d9e7f37c569d90a13fb9f0caaf7d04fe21"}, - {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3390e4333883673a684ce16c1716b481e91782d6f56dec5c85fed9feedb23382"}, - {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18a2e44ccb29cbb45071b8f3f2dab9ebfb41a6516f328f91f1f1fd18196239a4"}, - {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0961a27dc2057c38d0364cb05880e1997ae1c80220cbc847db63213720b8f304"}, - {file = "nh3-0.3.4-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:9337517edb7c10228252cce2898e20fb3d77e32ffaccbb3c66897927d74215a0"}, - {file = "nh3-0.3.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d866701affe67a5171b916b5c076e767a74c6a9efb7fb2006eb8d3c5f9a293d5"}, - {file = "nh3-0.3.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:47d749d99ae005ab19517224140b280dd56e77b33afb82f9b600e106d0458003"}, - {file = "nh3-0.3.4-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f987cb56458323405e8e5ea827e1befcf141ffa0c0ac797d6d02e6b646056d9a"}, - {file = "nh3-0.3.4-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:883d5a6d6ee8078c4afc8e96e022fe579c4c265775ff6ee21e39b8c542cabab3"}, - {file = "nh3-0.3.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:75643c22f5092d8e209f766ee8108c400bc1e44760fc94d2d638eb138d18f853"}, - {file = "nh3-0.3.4-cp38-abi3-win32.whl", hash = "sha256:72e4e9ca1c4bd41b4a28b0190edc2e21e3f71496acd36a0162858e1a28db3d7e"}, - {file = "nh3-0.3.4-cp38-abi3-win_amd64.whl", hash = "sha256:c10b1f0c741e257a5cb2978d6bac86e7c784ab20572724b20c6402c2e24bce75"}, - {file = "nh3-0.3.4-cp38-abi3-win_arm64.whl", hash = "sha256:43ad4eedee7e049b9069bc015b7b095d320ed6d167ecec111f877de1540656e9"}, - {file = "nh3-0.3.4.tar.gz", hash = "sha256:96709a379997c1b28c8974146ca660b0dcd3794f4f6d50c1ea549bab39ac6ade"}, + {file = "nh3-0.3.5-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:23a312224875f72cd16bde417f49071451877e29ef646a60e50fcb69407cc18a"}, + {file = "nh3-0.3.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:387abd011e81959d5a35151a11350a0795c6edeb53ebfa02d2e882dc01299263"}, + {file = "nh3-0.3.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48f45e3e914be93a596431aa143dedf1582557bf41a58153c296048d6e3798c9"}, + {file = "nh3-0.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0a09f51806fd51b4fedbf9ea2b61fef388f19aef0d62fe51199d41648be14588"}, + {file = "nh3-0.3.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c357f1d042c67f135a5e6babb2b0e3b9d9224ff4a3543240f597767b01384ffd"}, + {file = "nh3-0.3.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:38748140bf76383ab7ce2dce0ad4cb663855d8fbc9098f7f3483673d09616a17"}, + {file = "nh3-0.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:84bdeb082544fbcb77a12c034dd77d7da0556fdc0727b787eb6214b958c15e29"}, + {file = "nh3-0.3.5-cp314-cp314t-win32.whl", hash = "sha256:c3aae321f67ae66cff2a627115f106a377d4475d10b0e13d97959a13486b9a88"}, + {file = "nh3-0.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c88605d8d468f7fc1b31e06129bc91d6c96f6c621776c9b504a0da9beac9df5f"}, + {file = "nh3-0.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:72c5bdedec27fa33de6a5326346ea8aa3fe54f6ac294d54c4b204fb66a9f1e79"}, + {file = "nh3-0.3.5-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3bb854485c9b33e5bb143ff3e49e577073bc6bc320f0ff8fc316dd89c0d3c101"}, + {file = "nh3-0.3.5-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50d401ab2d8e86d59e2126e3ab2a2f45840c405842b626d9a51624b3a33b6878"}, + {file = "nh3-0.3.5-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acfd354e61accbe4c74f8017c6e397a776916dfe47c48643cf7fd84ade826f93"}, + {file = "nh3-0.3.5-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:52d877980d7ca01dc3baf3936bf844828bc6f332962227a684ed79c18cce14c3"}, + {file = "nh3-0.3.5-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:207c01801d3e9bb8ec08f08689346bdd30ce15b8bf60013a925d08b5388962a4"}, + {file = "nh3-0.3.5-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea232933394d1d58bf7c4bb348dc4660eae6604e1ae81cd2ba6d9ed80d390f3b"}, + {file = "nh3-0.3.5-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe3a787dc76b50de6bee54ef242f26c41dfe47654428e3e94f0fae5bb6dd2cc1"}, + {file = "nh3-0.3.5-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:488928988caad25ba14b1eb5bc74e25e21f3b5e40341d956f3ce4a8bc19460dc"}, + {file = "nh3-0.3.5-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c069570b06aa848457713ad7af4a9905691291548c4466a9ad78ee95808382b"}, + {file = "nh3-0.3.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eeedc90ed8c42c327e8e10e621ccfa314fc6cce35d5929f4297ff1cdb89667c4"}, + {file = "nh3-0.3.5-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:de8e8621853b6470fe928c684ee0d3f39ea8086cebafe4c416486488dea7b68d"}, + {file = "nh3-0.3.5-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:6ea58cc44d274c643b83547ca9654a0b1a817609b160601356f76a2b744c49ad"}, + {file = "nh3-0.3.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e49c9b564e6bcb03ecd2f057213df9a0de15a95812ac9db9600b590db23d3ae9"}, + {file = "nh3-0.3.5-cp38-abi3-win32.whl", hash = "sha256:559e4c73b689e9a7aa97ac9760b1bc488038d7c1a575aa4ab5a0e19ee9630c0f"}, + {file = "nh3-0.3.5-cp38-abi3-win_amd64.whl", hash = "sha256:45e6a65dc88a300a2e3502cb9c8e6d1d6b831d6fba7470643333609c6aab1f30"}, + {file = "nh3-0.3.5-cp38-abi3-win_arm64.whl", hash = "sha256:8f85285700a18e9f3fc5bff41fe573fa84f81542ef13b48a89f9fecca0474d3b"}, + {file = "nh3-0.3.5.tar.gz", hash = "sha256:45855e14ff056064fec77133bfcf7cd691838168e5e17bbef075394954dc9dc8"}, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, + {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, ] [[package]] @@ -1582,85 +1719,85 @@ files = [ [[package]] name = "numpy" -version = "2.4.4" +version = "2.4.6" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.11" groups = ["main"] markers = "python_version >= \"3.11\"" files = [ - {file = "numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db"}, - {file = "numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0"}, - {file = "numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015"}, - {file = "numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40"}, - {file = "numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d"}, - {file = "numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502"}, - {file = "numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd"}, - {file = "numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5"}, - {file = "numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e"}, - {file = "numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e"}, - {file = "numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e"}, - {file = "numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b"}, - {file = "numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e"}, - {file = "numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842"}, - {file = "numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8"}, - {file = "numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121"}, - {file = "numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e"}, - {file = "numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44"}, - {file = "numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d"}, - {file = "numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827"}, - {file = "numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a"}, - {file = "numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec"}, - {file = "numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50"}, - {file = "numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115"}, - {file = "numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af"}, - {file = "numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c"}, - {file = "numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103"}, - {file = "numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83"}, - {file = "numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed"}, - {file = "numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959"}, - {file = "numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed"}, - {file = "numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf"}, - {file = "numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d"}, - {file = "numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5"}, - {file = "numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7"}, - {file = "numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93"}, - {file = "numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e"}, - {file = "numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40"}, - {file = "numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e"}, - {file = "numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392"}, - {file = "numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008"}, - {file = "numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8"}, - {file = "numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233"}, - {file = "numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0"}, - {file = "numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a"}, - {file = "numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a"}, - {file = "numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b"}, - {file = "numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a"}, - {file = "numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d"}, - {file = "numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252"}, - {file = "numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f"}, - {file = "numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc"}, - {file = "numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74"}, - {file = "numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb"}, - {file = "numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e"}, - {file = "numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113"}, - {file = "numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d"}, - {file = "numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d"}, - {file = "numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f"}, - {file = "numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0"}, - {file = "numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150"}, - {file = "numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871"}, - {file = "numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e"}, - {file = "numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7"}, - {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4"}, - {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e"}, - {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c"}, - {file = "numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3"}, - {file = "numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7"}, - {file = "numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f"}, - {file = "numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119"}, - {file = "numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8"}, + {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538"}, + {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47"}, + {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93"}, + {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8"}, + {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6"}, + {file = "numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8"}, + {file = "numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147"}, + {file = "numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41"}, + {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698"}, + {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f"}, + {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853"}, + {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a"}, + {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2"}, + {file = "numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45"}, + {file = "numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751"}, + {file = "numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f"}, + {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3"}, + {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b"}, + {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089"}, + {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a"}, + {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605"}, + {file = "numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91"}, + {file = "numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359"}, + {file = "numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe"}, + {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997"}, + {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20"}, + {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d"}, + {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67"}, + {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd"}, + {file = "numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab"}, + {file = "numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75"}, + {file = "numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5"}, + {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096"}, + {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b"}, + {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8"}, + {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402"}, + {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb"}, + {file = "numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1"}, + {file = "numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261"}, + {file = "numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e"}, + {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e"}, + {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43"}, + {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e"}, + {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895"}, + {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4"}, + {file = "numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063"}, + {file = "numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627"}, + {file = "numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02"}, + {file = "numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73"}, + {file = "numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda"}, ] [[package]] @@ -1680,14 +1817,14 @@ attrs = ">=19.2.0" [[package]] name = "packaging" -version = "26.0" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev", "format", "release", "test"] +groups = ["main", "deps", "dev", "format", "release", "test"] files = [ - {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, - {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] @@ -1789,61 +1926,61 @@ xml = ["lxml (>=4.9.2)"] [[package]] name = "pandas" -version = "3.0.2" +version = "3.0.3" description = "Powerful data structures for data analysis, time series, and statistics" optional = false python-versions = ">=3.11" groups = ["main"] markers = "python_version >= \"3.11\"" files = [ - {file = "pandas-3.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0"}, - {file = "pandas-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c"}, - {file = "pandas-3.0.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:339dda302bd8369dedeae979cb750e484d549b563c3f54f3922cb8ff4978c5eb"}, - {file = "pandas-3.0.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61c2fd96d72b983a9891b2598f286befd4ad262161a609c92dc1652544b46b76"}, - {file = "pandas-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c934008c733b8bbea273ea308b73b3156f0181e5b72960790b09c18a2794fe1e"}, - {file = "pandas-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:60a80bb4feacbef5e1447a3f82c33209c8b7e07f28d805cfd1fb951e5cb443aa"}, - {file = "pandas-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed72cb3f45190874eb579c64fa92d9df74e98fd63e2be7f62bce5ace0ade61df"}, - {file = "pandas-3.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:f12b1a9e332c01e09510586f8ca9b108fd631fd656af82e452d7315ef6df5f9f"}, - {file = "pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18"}, - {file = "pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14"}, - {file = "pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d"}, - {file = "pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f"}, - {file = "pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab"}, - {file = "pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d"}, - {file = "pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4"}, - {file = "pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd"}, - {file = "pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3"}, - {file = "pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668"}, - {file = "pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9"}, - {file = "pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e"}, - {file = "pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d"}, - {file = "pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39"}, - {file = "pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991"}, - {file = "pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84"}, - {file = "pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235"}, - {file = "pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d"}, - {file = "pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7"}, - {file = "pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677"}, - {file = "pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172"}, - {file = "pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1"}, - {file = "pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0"}, - {file = "pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b"}, - {file = "pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288"}, - {file = "pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c"}, - {file = "pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535"}, - {file = "pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db"}, - {file = "pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53"}, - {file = "pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf"}, - {file = "pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb"}, - {file = "pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d"}, - {file = "pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8"}, - {file = "pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd"}, - {file = "pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d"}, - {file = "pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660"}, - {file = "pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702"}, - {file = "pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276"}, - {file = "pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482"}, - {file = "pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043"}, + {file = "pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98"}, + {file = "pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639"}, + {file = "pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2"}, + {file = "pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27"}, + {file = "pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824"}, + {file = "pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938"}, + {file = "pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea"}, + {file = "pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a"}, + {file = "pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09"}, + {file = "pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4"}, + {file = "pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c"}, + {file = "pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9"}, + {file = "pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf"}, + {file = "pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c"}, + {file = "pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc"}, + {file = "pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49"}, + {file = "pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa"}, + {file = "pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7"}, + {file = "pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8"}, + {file = "pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a"}, + {file = "pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb"}, + {file = "pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2"}, + {file = "pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44"}, + {file = "pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e"}, + {file = "pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d"}, + {file = "pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066"}, + {file = "pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd"}, + {file = "pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085"}, + {file = "pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870"}, + {file = "pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f"}, + {file = "pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13"}, + {file = "pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac"}, + {file = "pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f"}, + {file = "pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb"}, + {file = "pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a"}, + {file = "pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360"}, + {file = "pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76"}, + {file = "pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5"}, + {file = "pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977"}, + {file = "pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04"}, + {file = "pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6"}, + {file = "pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c"}, + {file = "pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028"}, + {file = "pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d"}, + {file = "pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a"}, + {file = "pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1"}, + {file = "pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1"}, + {file = "pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc"}, ] [package.dependencies] @@ -1855,7 +1992,7 @@ python-dateutil = ">=2.8.2" tzdata = {version = "*", markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\""} [package.extras] -all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)", "beautifulsoup4 (>=4.12.3)", "bottleneck (>=1.4.2)", "fastparquet (>=2024.11.0)", "fsspec (>=2024.10.0)", "gcsfs (>=2024.10.0)", "html5lib (>=1.1)", "hypothesis (>=6.116.0)", "jinja2 (>=3.1.5)", "lxml (>=5.3.0)", "matplotlib (>=3.9.3)", "numba (>=0.60.0)", "numexpr (>=2.10.2)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "psycopg2 (>=2.9.10)", "pyarrow (>=13.0.0)", "pyiceberg (>=0.8.1)", "pymysql (>=1.1.1)", "pyreadstat (>=1.2.8)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)", "python-calamine (>=0.3.0)", "pytz (>=2024.2)", "pyxlsb (>=1.0.10)", "qtpy (>=2.4.2)", "s3fs (>=2024.10.0)", "scipy (>=1.14.1)", "tables (>=3.10.1)", "tabulate (>=0.9.0)", "xarray (>=2024.10.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)", "zstandard (>=0.23.0)"] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)", "beautifulsoup4 (>=4.12.3)", "bottleneck (>=1.4.2)", "fastparquet (>=2024.11.0)", "fsspec (>=2024.10.0)", "gcsfs (>=2024.10.0)", "html5lib (>=1.1)", "hypothesis (>=6.116.0)", "jinja2 (>=3.1.5)", "lxml (>=5.3.0)", "matplotlib (>=3.9.3)", "numba (>=0.60.0)", "numexpr (>=2.10.2)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "psycopg2 (>=2.9.10)", "pyarrow (>=13.0.0)", "pyiceberg (>=0.8.1)", "pymysql (>=1.1.1)", "pyreadstat (>=1.2.8)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)", "python-calamine (>=0.3.0)", "pytz (>=2020.1)", "pyxlsb (>=1.0.10)", "qtpy (>=2.4.2)", "s3fs (>=2024.10.0)", "scipy (>=1.14.1)", "tables (>=3.10.1)", "tabulate (>=0.9.0)", "xarray (>=2024.10.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)", "zstandard (>=0.23.0)"] aws = ["s3fs (>=2024.10.0)"] clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.4.2)"] compression = ["zstandard (>=0.23.0)"] @@ -1877,26 +2014,25 @@ pyarrow = ["pyarrow (>=13.0.0)"] spss = ["pyreadstat (>=1.2.8)"] sql-other = ["SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)"] test = ["hypothesis (>=6.116.0)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)"] -timezone = ["pytz (>=2024.2)"] +timezone = ["pytz (>=2020.1)"] xml = ["lxml (>=5.3.0)"] [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" groups = ["format", "types"] files = [ - {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, - {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, ] [package.extras] hyperscan = ["hyperscan (>=0.7)"] optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] -tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] [[package]] name = "pep8-naming" @@ -1915,14 +2051,14 @@ flake8 = ">=5.0.0" [[package]] name = "platformdirs" -version = "4.9.4" +version = "4.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["dev", "format"] files = [ - {file = "platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"}, - {file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"}, + {file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"}, + {file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"}, ] [[package]] @@ -1941,6 +2077,25 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pre-commit" +version = "4.6.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b"}, + {file = "pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "pycodestyle" version = "2.14.0" @@ -2124,14 +2279,14 @@ typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.1" description = "Settings management using Pydantic" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"}, - {file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"}, + {file = "pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de"}, + {file = "pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa"}, ] [package.dependencies] @@ -2140,7 +2295,7 @@ python-dotenv = ">=0.21.0" typing-inspection = ">=0.4.0" [package.extras] -aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +aws-secrets-manager = ["boto3 (>=1.35.0)", "types-boto3[secretsmanager]"] azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] @@ -2373,41 +2528,37 @@ certifi = "*" [[package]] name = "pyproject-api" -version = "1.10.0" +version = "1.10.1" description = "API to interact with the python pyproject.toml based projects" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09"}, - {file = "pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330"}, + {file = "pyproject_api-1.10.1-py3-none-any.whl", hash = "sha256:fa9e6f66c35b5017e909825d8f2b5d5482ea699d7be809d21c03bd1f7317f36a"}, + {file = "pyproject_api-1.10.1.tar.gz", hash = "sha256:c2b2726bd7aa9217b6c50b621fef5b2ae5def4d55b779c9e0694c15e0a8517ba"}, ] [package.dependencies] packaging = ">=25" tomli = {version = ">=2.3", markers = "python_version < \"3.11\""} -[package.extras] -docs = ["furo (>=2025.9.25)", "sphinx-autodoc-typehints (>=3.5.1)"] -testing = ["covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)", "setuptools (>=80.9)"] - [[package]] name = "pytest" -version = "8.4.2" +version = "9.1.0" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["test"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32"}, + {file = "pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} @@ -2469,14 +2620,14 @@ six = ">=1.5" [[package]] name = "python-discovery" -version = "1.2.1" +version = "1.4.2" description = "Python interpreter discovery" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502"}, - {file = "python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e"}, + {file = "python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500"}, + {file = "python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690"}, ] [package.dependencies] @@ -2484,7 +2635,7 @@ filelock = ">=3.15.4" platformdirs = ">=4.3.6,<5" [package.extras] -docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)"] +docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)", "sphinxcontrib-towncrier (>=0.4)", "towncrier (>=25.8)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] [[package]] @@ -2559,14 +2710,15 @@ dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "t [[package]] name = "pytz" -version = "2026.1.post1" +version = "2026.2" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" groups = ["main"] +markers = "python_version == \"3.10\"" files = [ - {file = "pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a"}, - {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, + {file = "pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126"}, + {file = "pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a"}, ] [[package]] @@ -2588,7 +2740,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["security"] +groups = ["main", "dev", "security"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -2667,14 +2819,14 @@ files = [ [[package]] name = "readme-renderer" -version = "44.0" +version = "45.0" description = "readme_renderer is a library for rendering readme descriptions for Warehouse" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["release"] files = [ - {file = "readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151"}, - {file = "readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1"}, + {file = "readme_renderer-45.0-py3-none-any.whl", hash = "sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f"}, + {file = "readme_renderer-45.0.tar.gz", hash = "sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1"}, ] [package.dependencies] @@ -2683,18 +2835,18 @@ nh3 = ">=0.2.14" Pygments = ">=2.5.1" [package.extras] -md = ["cmarkgfm (>=0.8.0)"] +md = ["comrak (>=0.0.11)"] [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" groups = ["main", "release"] files = [ - {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, - {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] @@ -2722,6 +2874,21 @@ files = [ [package.dependencies] requests = ">=2.0.1,<3.0.0" +[[package]] +name = "requirements-parser" +version = "0.13.0" +description = "This is a small Python module for parsing Pip requirement files." +optional = false +python-versions = "<4.0,>=3.8" +groups = ["deps"] +files = [ + {file = "requirements_parser-0.13.0-py3-none-any.whl", hash = "sha256:2b3173faecf19ec5501971b7222d38f04cb45bb9d87d0ad629ca71e2e62ded14"}, + {file = "requirements_parser-0.13.0.tar.gz", hash = "sha256:0843119ca2cb2331de4eb31b10d70462e39ace698fd660a915c247d2301a4418"}, +] + +[package.dependencies] +packaging = ">=23.2" + [[package]] name = "rfc3986" version = "2.0.0" @@ -2739,14 +2906,14 @@ idna2008 = ["idna"] [[package]] name = "rich" -version = "14.3.3" +version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.9.0" groups = ["release", "security"] files = [ - {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, - {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, + {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, + {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, ] [package.dependencies] @@ -2873,14 +3040,14 @@ files = [ [[package]] name = "snowballstemmer" -version = "3.0.1" -description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." +version = "3.1.1" +description = "This package provides 36 stemmers for 34 languages generated from Snowball algorithms." optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" +python-versions = ">=3.3" groups = ["lint"] files = [ - {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, - {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, + {file = "snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752"}, + {file = "snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260"}, ] [[package]] @@ -2916,14 +3083,14 @@ full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart [[package]] name = "stevedore" -version = "5.7.0" +version = "5.8.0" description = "Manage dynamic plugins for Python applications" optional = false python-versions = ">=3.10" groups = ["main", "security"] files = [ - {file = "stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed"}, - {file = "stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3"}, + {file = "stevedore-5.8.0-py3-none-any.whl", hash = "sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b"}, + {file = "stevedore-5.8.0.tar.gz", hash = "sha256:b49867b32ca3016e94100e68dbf26e72aa7b8708d0a3f73c08aeb220370ac715"}, ] [[package]] @@ -2932,8 +3099,7 @@ version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["dev", "format", "test", "types"] -markers = "python_version == \"3.10\"" +groups = ["deps", "dev", "format", "test", "types"] files = [ {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, @@ -2983,34 +3149,67 @@ files = [ {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] +markers = {dev = "python_version == \"3.10\"", format = "python_version == \"3.10\"", test = "python_version == \"3.10\"", types = "python_version == \"3.10\""} + +[[package]] +name = "tomli-w" +version = "1.2.0" +description = "A lil' TOML writer" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, + {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, +] [[package]] name = "tox" -version = "4.16.0" +version = "4.55.1" description = "tox is a generic virtualenv management and test command line tool" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "tox-4.16.0-py3-none-any.whl", hash = "sha256:61e101061b977b46cf00093d4319438055290ad0009f84497a07bf2d2d7a06d0"}, - {file = "tox-4.16.0.tar.gz", hash = "sha256:43499656f9949edb681c0f907f86fbfee98677af9919d8b11ae5ad77cb800748"}, + {file = "tox-4.55.1-py3-none-any.whl", hash = "sha256:e2084be6dfdef96ba1bed4948e6a1f73613d6952e1477be5dca45653d4c053c8"}, + {file = "tox-4.55.1.tar.gz", hash = "sha256:0678fbf26dd5b559b1ef128fa4388325920219322ebc8cc5f3497627c00f4472"}, ] [package.dependencies] -cachetools = ">=5.3.3" -chardet = ">=5.2" +cachetools = ">=7.0.3" colorama = ">=0.4.6" -filelock = ">=3.15.4" -packaging = ">=24.1" -platformdirs = ">=4.2.2" -pluggy = ">=1.5" -pyproject-api = ">=1.7.1" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.26.3" +filelock = ">=3.25" +packaging = ">=26" +platformdirs = ">=4.9.4" +pluggy = ">=1.6" +pyproject-api = ">=1.10" +python-discovery = ">=1.2.2" +tomli = {version = ">=2.4", markers = "python_version < \"3.11\""} +tomli-w = ">=1.2" +typing-extensions = {version = ">=4.15", markers = "python_version < \"3.11\""} +virtualenv = ">=21.1" + +[package.extras] +completion = ["argcomplete (>=3.6.3)"] +testing = ["devpi-process (>=1.1.1)", "pytest (>=9.0.2)", "pytest-mock (>=3.15.1)"] + +[[package]] +name = "tox-gh-actions" +version = "3.5.0" +description = "Seamless integration of tox into GitHub Actions" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "tox_gh_actions-3.5.0-py3-none-any.whl", hash = "sha256:070790114c92f4c94337047515ca5e077ceb269a5cb9366e0a0b3440a024eca1"}, + {file = "tox_gh_actions-3.5.0.tar.gz", hash = "sha256:cc8e148c4513042e5019973e5672594c3df241b035e7fb550f6f778588110051"}, +] + +[package.dependencies] +tox = ">=4,<5" [package.extras] -docs = ["furo (>=2024.5.6)", "sphinx (>=7.3.7)", "sphinx-argparse-cli (>=1.16)", "sphinx-autodoc-typehints (>=2.2.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] -testing = ["build[virtualenv] (>=1.2.1)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=70.2)", "time-machine (>=2.14.2) ; implementation_name != \"pypy\"", "wheel (>=0.43)"] +testing = ["devpi-process", "mypy ; platform_python_implementation == \"CPython\"", "pre-commit", "pytest (>=7)", "pytest-cov (>=4)", "pytest-mock (>=3)", "pytest-randomly (>=3)"] [[package]] name = "trio" @@ -3061,14 +3260,14 @@ keyring = ["keyring (>=21.2.0)"] [[package]] name = "types-requests" -version = "2.33.0.20260402" +version = "2.33.0.20260518" description = "Typing stubs for requests" optional = false python-versions = ">=3.10" groups = ["types"] files = [ - {file = "types_requests-2.33.0.20260402-py3-none-any.whl", hash = "sha256:c98372d7124dd5d10af815ee25c013897592ff92af27b27e22c98984102c3254"}, - {file = "types_requests-2.33.0.20260402.tar.gz", hash = "sha256:1bdd3ada9b869741c5c4b887d2c8b4e38284a1449751823b5ebbccba3eefd9da"}, + {file = "types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0"}, + {file = "types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e"}, ] [package.dependencies] @@ -3085,7 +3284,7 @@ files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {dev = "python_version == \"3.10\"", format = "python_version == \"3.10\"", release = "python_version == \"3.10\" and sys_platform == \"linux\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"", test = "python_version == \"3.10\""} +markers = {dev = "python_version == \"3.10\"", format = "python_version == \"3.10\"", release = "python_version == \"3.10\" and sys_platform == \"linux\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"", test = "python_version <= \"3.12\""} [[package]] name = "typing-inspection" @@ -3104,27 +3303,27 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2026.1" +version = "2026.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\" or python_version == \"3.10\"" files = [ - {file = "tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9"}, - {file = "tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98"}, + {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, + {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, ] [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main", "release", "types"] files = [ - {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, - {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] @@ -3155,65 +3354,61 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [[package]] name = "virtualenv" -version = "21.2.0" +version = "21.5.0" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f"}, - {file = "virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098"}, + {file = "virtualenv-21.5.0-py3-none-any.whl", hash = "sha256:8f7c38605023688c89789f566959006af6d61c99eeeb9e58342eb780c5761e5e"}, + {file = "virtualenv-21.5.0.tar.gz", hash = "sha256:98847aadf5e2037e0e4d2e19528eb3aca6f23906422e59a510bff231a6d32fce"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" -python-discovery = ">=1" +python-discovery = ">=1.4.2" typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} [[package]] name = "wattnet-storage" -version = "1.0.0" -description = "Storage Client for wattnet" +version = "1.1.0" +description = "Extensible plugin-based client for unified wattnet time-series metric storage across multiple backends." optional = false -python-versions = ">=3.10,<3.15" +python-versions = "<3.15,>=3.10" groups = ["main"] -files = [] -develop = false +files = [ + {file = "wattnet_storage-1.1.0-py3-none-any.whl", hash = "sha256:57d5ba43583db1a3459485ca3a86e17e17b756a9565dd48a4bcb2934cbaae74b"}, + {file = "wattnet_storage-1.1.0.tar.gz", hash = "sha256:5ce6c2bef1c9b6a59a3c387a15b8068e7a5742f4a8f5ca7dc2d7f8f5712305ba"}, +] [package.dependencies] -clickhouse-connect = "^0.10.0" -pydantic-settings = "^2.13.1" -requests = "^2.33.0" -stevedore = "^5.7.0" - -[package.source] -type = "git" -url = "ssh://git@github.com/wattnet/wattnet-storage.git" -reference = "HEAD" -resolved_reference = "c7ef06e74f08e5688fe46ff680e101d22865d813" +clickhouse-connect = {version = ">=1.0,<2.0", extras = ["pandas"]} +pydantic = ">=2.0,<3.0" +pydantic-settings = ">=2.0,<3.0" +stevedore = ">=5.7.0,<6.0.0" [[package]] name = "zipp" -version = "3.23.0" +version = "4.1.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["release"] markers = "python_version < \"3.12\" and platform_machine != \"ppc64le\" and platform_machine != \"s390x\"" files = [ - {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, + {file = "zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f"}, + {file = "zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] +enabler = ["pytest-enabler (>=3.4)"] test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] [[package]] name = "zstandard" @@ -3330,4 +3525,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.15" -content-hash = "5c6db81513595eb813d0d60204f9981ef84ab673eb12073dfc38fd91fdee7dc7" +content-hash = "1104dcdfa4a8c1765172a5bf7cc5fb1eccb2c328f1f21909300f4de1c9750f67" diff --git a/pyproject.toml b/pyproject.toml index c3855a1..8f6ba70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,13 +2,46 @@ name = "wattnet-api" version = "1.0.0" description = "REST API for wattnet" -authors = ["Jaime Iglesias Blanco "] +license = "Apache-2.0" +authors = ["Jaime Iglesias Blanco "] readme = "README.md" homepage = "https://wattnet.eu" repository = "https://github.com/wattnet/wattnet-api" documentation = "https://api.wattnet.eu/docs" +keywords = [ + "energy", "electricity", "carbon", "carbon-footprint", + "water", "water-footprint", "water-impact", "greenscore", + "api", "rest", "fastapi", + "sustainability", "wattnet", +] packages = [{ include = "wattnet" }] -include = ["config/.env.example"] +include = [ + "config/.env.example", + { path = "data", format = ["sdist", "wheel"] }, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Framework :: FastAPI", + "Topic :: Internet :: WWW/HTTP :: HTTP Servers", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Information Analysis", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] + +[tool.poetry.scripts] +wattnet-api = "wattnet.api.app:main" [tool.poetry.dependencies] python = ">=3.10,<3.15" @@ -22,18 +55,19 @@ shapely = "2.1.2" trio = "0.32.0" pydantic-settings = "^2.12.0" typing-extensions = "4.15.0" -wattnet-storage = { git = "ssh://git@github.com/wattnet/wattnet-storage.git" } - -[tool.poetry.scripts] -wattnet-api = "wattnet.api.app:main" +wattnet-storage = "^1.1.0" +pyyaml = "^6.0" [tool.poetry.group.test.dependencies] -pytest = "^8.0" +pytest = "^9.0" pytest-trio = "^0.8.0" pytest-cov = "^7.0.0" +httpx = ">=0.23" [tool.poetry.group.dev.dependencies] -tox = "4.16.0" +tox = "^4.16.0" +tox-gh-actions = "^3.0" +pre-commit = "^4.0" [tool.poetry.group.lint.dependencies] flake8 = ">=4,<8" @@ -46,17 +80,70 @@ pydocstyle = "^6.1" [tool.poetry.group.format.dependencies] black = ">=22.3,<27.0" +isort = "^8.0" [tool.poetry.group.security.dependencies] -bandit = "1.9.3" +bandit = "^1.9.0" + +[tool.poetry.group.deps.dependencies] +deptry = ">=0.23" [tool.poetry.group.types.dependencies] -mypy = "1.19.1" +mypy = "^2.0" types-requests = "^2.31.0" [tool.poetry.group.release.dependencies] twine = ">=4.0.2,<7.0.0" +[tool.pytest.ini_options] +testpaths = ["tests/unit", "tests/integration"] +addopts = "--tb=short" +markers = [ + "integration: requires a running API instance", +] + +[tool.coverage.run] +source = ["wattnet"] +omit = ["*/tests/*", "*/__pycache__/*"] + +[tool.black] +target-version = ["py310", "py311", "py312", "py313", "py314"] + +[tool.deptry] +known_first_party = ["wattnet"] + +[tool.deptry.per_rule_ignores] +DEP002 = [ + "pytest", "pytest-cov", "pytest-trio", + "tox", "tox-gh-actions", + "pre-commit", + "flake8", "flake8-bugbear", "flake8-docstrings", "flake8-typing-imports", "flake8-colors", + "pep8-naming", "pydocstyle", + "black", "isort", + "bandit", + "mypy", + "twine", + "deptry", +] + +[tool.isort] +profile = "black" +known_first_party = ["wattnet"] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true +strict_optional = true +ignore_missing_imports = true +explicit_package_bases = true + +[[tool.mypy.overrides]] +module = "wattnet.*" +disallow_untyped_defs = true + [build-system] -requires = ["poetry-core"] +requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..6568a6b --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "python", + "packages": { + ".": {} + }, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation" }, + { "type": "build", "section": "Build System" }, + { "type": "ci", "section": "CI/CD", "hidden": true }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "refactor", "section": "Code Refactoring", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true } + ] +} diff --git a/scripts/check_wheel.py b/scripts/check_wheel.py new file mode 100644 index 0000000..4570077 --- /dev/null +++ b/scripts/check_wheel.py @@ -0,0 +1,21 @@ +"""Verify that the wattnet-api wheel bundles the required data files.""" + +import pathlib +import sys +import zipfile + +whl = next(pathlib.Path(sys.argv[1]).glob("*.whl")) +names = zipfile.ZipFile(whl).namelist() + +required = [ + "data/geojson/", + "data/zones/entsoe_selected_zones_2026.yaml", + "data/zones/entsoe_selected_crossborders_2026.yaml", +] +missing = [r for r in required if not any(r in n for n in names)] + +if missing: + print(f"FAIL {whl.name}: missing {missing}") + sys.exit(1) + +print(f"OK {whl.name}: all data files present") diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100755 index 0000000..1faac28 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -e + +# Read a WATTNET_API_* setting honouring the same priority as pydantic-settings: +# 1. OS environment variable 2. /etc/wattnet/api.env 3. /app/.env 4. default +_cfg() { + key="$1"; default="$2" + eval "val=\${${key}:-}" + if [ -n "$val" ]; then echo "$val"; return; fi + for f in /etc/wattnet/api.env /app/.env; do + if [ -f "$f" ]; then + val=$(grep -E "^${key}=" "$f" 2>/dev/null | head -1 | cut -d= -f2-) + if [ -n "$val" ]; then echo "$val"; return; fi + fi + done + echo "$default" +} + +WORKERS=$(_cfg WATTNET_API_WORKERS 1) +PORT=$(_cfg WATTNET_API_PORT 8000) + +exec gunicorn wattnet.api:app \ + --worker-class uvicorn.workers.UvicornWorker \ + --workers "$WORKERS" \ + --bind "0.0.0.0:$PORT" \ + --access-logfile - \ + --error-logfile - diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e6413f2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +""" +Shared pytest configuration for the wattnet-api test suite. + +Patches the ClickHouse storage manager initialisation so that unit tests can +import any wattnet.api.* module without requiring a live ClickHouse instance. +The patch is installed in pytest_configure, which runs before test collection, +so the mock is in place when test files (and their imports) are first loaded. +""" + +from __future__ import annotations + +from unittest.mock import patch + + +def pytest_configure(config: object) -> None: + """Prevent ClickHouse connection attempts during module import. + + :param config: pytest config object (unused) + :return: None + :rtype: None + """ + patch( + "wattnet.storage.clients.manager.StorageClientsManager.__init__", + return_value=None, + ).start() diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..b86ff6b --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,91 @@ +""" +Shared fixtures for integration tests of the wattnet API. + +Integration tests use the real versioned FastAPI application with a single +mock boundary: MetricsRepository.query_metrics. This lets tests exercise the +full router → validation → service → model-serialisation pipeline without +requiring a live ClickHouse instance. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from tests.unit.service.helpers import FakeMetric # noqa: F401 – re-exported for tests + + +@pytest.fixture(scope="session") +def client() -> TestClient: + """Return a TestClient for the full versioned FastAPI application. + + All v1 endpoints are reachable at /v1/. + + The import is deferred to fixture time so that the StorageClientsManager + patch from tests/conftest.py pytest_configure has already been applied + before the app module is loaded. + + :return: TestClient bound to versioned_app + :rtype: TestClient + """ + from wattnet.api.app import versioned_app + + return TestClient(versioned_app) + + +@pytest.fixture +def mock_db(monkeypatch): + """Return a callable that configures what MetricsRepository.query_metrics returns. + + Usage:: + + def test_something(client, mock_db): + mock_db([metric1, metric2]) + r = client.get("/v1/generation") + assert r.status_code == 200 + + The patch applies to the MetricsRepository *class*, so all service + singletons (which share the same repo instance) use the mocked version. + + :param monkeypatch: pytest monkeypatch fixture + :return: callable(metrics) that sets the return value for query_metrics + """ + + def _configure(metrics): + monkeypatch.setattr( + "wattnet.storage.repository.MetricsRepository.query_metrics", + lambda self, *args, **kwargs: metrics, + ) + + return _configure + + +@pytest.fixture +def mock_geo(monkeypatch): + """Return a callable that configures what geo.get_zone_code returns. + + Usage:: + + def test_latlon(client, mock_db, mock_geo): + mock_geo("ES") # zone found + mock_db([]) + r = client.get("/v1/generation?lat=40&lon=-3") + assert r.status_code == 200 + + def test_latlon_notfound(client, mock_db, mock_geo): + mock_geo(None) # no zone + mock_db([]) + r = client.get("/v1/generation?lat=0&lon=0") + assert r.status_code == 404 + + :param monkeypatch: pytest monkeypatch fixture + :return: callable(zone_or_none) that sets the return value for get_zone_code + """ + + def _configure(zone): + monkeypatch.setattr( + "wattnet.api.utils.validation.geo.get_zone_code", + lambda lat, lon: zone, + ) + + return _configure diff --git a/tests/integration/test_app.py b/tests/integration/test_app.py new file mode 100644 index 0000000..44f82a0 --- /dev/null +++ b/tests/integration/test_app.py @@ -0,0 +1,29 @@ +"""Integration tests for the top-level versioned app (favicon, main).""" + +from __future__ import annotations + +from unittest.mock import patch + + +def test_favicon_returns_200(client) -> None: + """GET /favicon.ico must return 200. + + :return: None + :rtype: None + """ + r = client.get("/favicon.ico") + assert r.status_code == 200 + + +def test_main_calls_uvicorn_run() -> None: + """main() must invoke uvicorn.run once. + + :return: None + :rtype: None + """ + from wattnet.api.app import main + + with patch("uvicorn.run") as mock_run: + main() + + mock_run.assert_called_once() diff --git a/tests/integration/test_exports.py b/tests/integration/test_exports.py new file mode 100644 index 0000000..46e7847 --- /dev/null +++ b/tests/integration/test_exports.py @@ -0,0 +1,124 @@ +"""Integration tests for GET /v1/exports.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/exports" + + +def _m(**kw) -> FakeMetric: + d = {"zone": "ES", "unit": "MW", "to": "FR", "data_state": "official", + "datasource": "ENTSO-E", "valid": "true", "zone_status": "complete"} + d.update(kw) + return FakeMetric(_NOW, 500.0, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(_URL) + assert r.status_code == 200 + assert r.json() == [] + + +def test_returns_export_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested Export object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["series"][0]["exports"][0]["destination"] == "FR" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased before service call. + + :return: None + :rtype: None + """ + mock_db([_m(zone="PT")]) + assert client.get(f"{_URL}?zone=pt").json()[0]["zone"] == "PT" + + +def test_lat_lon_found(client, mock_db, mock_geo) -> None: + """Valid coordinates returning a zone produce 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_m()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_not_found(client, mock_db, mock_geo) -> None: + """Coordinates with no matching zone return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_lat_without_lon_returns_400(client, mock_db) -> None: + """Providing lat without lon must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?lat=40").status_code == 400 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 + + +def test_start_after_end_returns_400(client, mock_db) -> None: + """start > end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(f"{_URL}?start=2025-01-02T00:00:00Z&end=2025-01-01T00:00:00Z") + assert r.status_code == 400 + + +def test_destination_zone_and_destination_lat_returns_400(client, mock_db) -> None: + """Providing both destination_zone and destination_lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?destination_zone=FR&destination_lat=48").status_code == 400 diff --git a/tests/integration/test_factors.py b/tests/integration/test_factors.py new file mode 100644 index 0000000..dc6d237 --- /dev/null +++ b/tests/integration/test_factors.py @@ -0,0 +1,126 @@ +"""Integration tests for GET /v1/factors.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/factors" + + +def _m(**kw) -> FakeMetric: + d = {"factor_type": "carbon", "production_type": "solar", + "scope": "operational", "unit": "gCO2/kWh", + "source": "IPCC", "year": 2023, "source_link": "https://ipcc.ch"} + d.update(kw) + return FakeMetric(_NOW, 25.0, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(_URL).status_code == 200 + assert client.get(_URL).json() == [] + + +def test_returns_factor_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested Factor object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["factor_type"] == "carbon" + assert body[0]["production_type"] == "solar" + assert body[0]["scope"] == "operational" + + +def test_invalid_factor_type_returns_400(client, mock_db) -> None: + """Unknown factor_type must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?factor_type=electric").status_code == 400 + + +def test_valid_factor_type_passes(client, mock_db) -> None: + """Known factor_type must be accepted. + + :return: None + :rtype: None + """ + mock_db([_m(factor_type="water")]) + assert client.get(f"{_URL}?factor_type=water").status_code == 200 + + +def test_invalid_scope_returns_400(client, mock_db) -> None: + """Unknown scope must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?scope=political").status_code == 400 + + +def test_valid_scope_passes(client, mock_db) -> None: + """Known scope must be accepted. + + :return: None + :rtype: None + """ + mock_db([_m(scope="life-cycle")]) + assert client.get(f"{_URL}?scope=life-cycle").status_code == 200 + + +def test_invalid_production_type_returns_400(client, mock_db) -> None: + """Unknown production_type must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?production_type=fusion").status_code == 400 + + +def test_valid_production_type_passes(client, mock_db) -> None: + """Known production_type must be accepted. + + :return: None + :rtype: None + """ + mock_db([_m(production_type="wind_onshore")]) + assert client.get(f"{_URL}?production_type=wind_onshore").status_code == 200 + + +def test_aggregate_with_dates_returns_200(client, mock_db) -> None: + """aggregate=true with start/end must return 200. + + :return: None + :rtype: None + """ + mock_db([_m()]) + r = client.get( + f"{_URL}?aggregate=true" + "&start=2025-06-01T00:00:00Z&end=2025-06-02T00:00:00Z" + ) + assert r.status_code == 200 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 diff --git a/tests/integration/test_flow_share.py b/tests/integration/test_flow_share.py new file mode 100644 index 0000000..260229a --- /dev/null +++ b/tests/integration/test_flow_share.py @@ -0,0 +1,123 @@ +"""Integration tests for GET /v1/flow_share.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/flow-share" + + +def _m(**kw) -> FakeMetric: + d = {"zone": "ES", "unit": "%", "target": "FR", + "valid": "true", "zone_status": "complete"} + d.update(kw) + return FakeMetric(_NOW, 15.0, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(_URL).status_code == 200 + assert client.get(_URL).json() == [] + + +def test_returns_flow_share_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested FlowShare object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["series"][0]["flows"][0]["destination"] == "FR" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased before service call. + + :return: None + :rtype: None + """ + mock_db([_m(zone="PT")]) + assert client.get(f"{_URL}?zone=pt").json()[0]["zone"] == "PT" + + +def test_lat_lon_found(client, mock_db, mock_geo) -> None: + """Valid coordinates returning a zone produce 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_m()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_not_found(client, mock_db, mock_geo) -> None: + """Coordinates with no matching zone return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_lat_without_lon_returns_400(client, mock_db) -> None: + """Providing lat without lon must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?lat=40").status_code == 400 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 + + +def test_destination_zone_and_destination_lat_returns_400(client, mock_db) -> None: + """Providing both destination_zone and destination_lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?destination_zone=FR&destination_lat=48").status_code == 400 + + +def test_start_and_end_returns_200(client, mock_db) -> None: + """Valid start and end datetimes must return 200. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(f"{_URL}?start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z") + assert r.status_code == 200 diff --git a/tests/integration/test_footprint_share.py b/tests/integration/test_footprint_share.py new file mode 100644 index 0000000..2a84225 --- /dev/null +++ b/tests/integration/test_footprint_share.py @@ -0,0 +1,134 @@ +"""Integration tests for GET /v1/footprint_share.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/footprint-share" + + +def _m(**kw) -> FakeMetric: + d = {"zone": "ES", "unit": "gCO2/kWh", "footprint_type": "carbon", + "scope": "life-cycle", "source": "FR", "valid": "true", "zone_status": "complete"} + d.update(kw) + return FakeMetric(_NOW, 180.0, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(_URL).status_code == 200 + assert client.get(_URL).json() == [] + + +def test_returns_footprint_share_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested FootprintShare object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["footprint_type"] == "carbon" + assert body[0]["series"][0]["blocks"][0]["source"] == "FR" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased before service call. + + :return: None + :rtype: None + """ + mock_db([_m(zone="PT")]) + assert client.get(f"{_URL}?zone=pt").json()[0]["zone"] == "PT" + + +def test_lat_lon_found(client, mock_db, mock_geo) -> None: + """Valid coordinates returning a zone produce 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_m()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_not_found(client, mock_db, mock_geo) -> None: + """Coordinates with no matching zone return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_invalid_footprint_type_returns_400(client, mock_db) -> None: + """Unknown footprint_type must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?footprint_type=invalid").status_code == 400 + + +def test_invalid_scope_returns_400(client, mock_db) -> None: + """Unknown scope must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?scope=political").status_code == 400 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 + + +def test_source_zone_and_source_lat_returns_400(client, mock_db) -> None: + """Providing both source_zone and source_lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?source_zone=FR&source_lat=48").status_code == 400 + + +def test_start_and_end_returns_200(client, mock_db) -> None: + """Valid start and end datetimes must return 200. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(f"{_URL}?start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z") + assert r.status_code == 200 diff --git a/tests/integration/test_footprints.py b/tests/integration/test_footprints.py new file mode 100644 index 0000000..b8dbf3c --- /dev/null +++ b/tests/integration/test_footprints.py @@ -0,0 +1,162 @@ +"""Integration tests for GET /v1/footprints.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/footprints" + + +def _m(**kw) -> FakeMetric: + d = { + "zone": "ES", + "unit": "gCO2/kWh", + "footprint_type": "carbon", + "scope": "life-cycle", + "valid": "true", + "zone_status": "complete", + } + d.update(kw) + return FakeMetric(_NOW, 250.0, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(_URL).status_code == 200 + assert client.get(_URL).json() == [] + + +def test_returns_footprint_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested Footprint object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["footprint_type"] == "carbon" + assert body[0]["scope"] == "life-cycle" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased before service call. + + :return: None + :rtype: None + """ + mock_db([_m(zone="PT")]) + assert client.get(f"{_URL}?zone=pt").json()[0]["zone"] == "PT" + + +def test_lat_lon_found(client, mock_db, mock_geo) -> None: + """Valid coordinates returning a zone produce 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_m()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_not_found(client, mock_db, mock_geo) -> None: + """Coordinates with no matching zone return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_lat_without_lon_returns_400(client, mock_db) -> None: + """Providing lat without lon must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?lat=40").status_code == 400 + + +def test_invalid_footprint_type_returns_400(client, mock_db) -> None: + """Unknown footprint_type must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?footprint_type=invalid").status_code == 400 + + +def test_valid_footprint_type_passes(client, mock_db) -> None: + """Known footprint_type must be accepted. + + :return: None + :rtype: None + """ + mock_db([_m(footprint_type="water")]) + assert client.get(f"{_URL}?footprint_type=water").status_code == 200 + + +def test_invalid_scope_returns_400(client, mock_db) -> None: + """Unknown scope must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?scope=invalid").status_code == 400 + + +def test_aggregate_without_dates_returns_400(client, mock_db) -> None: + """aggregate=true without start/end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?aggregate=true").status_code == 400 + + +def test_aggregate_with_dates_returns_200(client, mock_db) -> None: + """aggregate=true with start/end must return 200. + + :return: None + :rtype: None + """ + mock_db([_m()]) + r = client.get( + f"{_URL}?aggregate=true" "&start=2025-06-01T00:00:00Z&end=2025-06-02T00:00:00Z" + ) + assert r.status_code == 200 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 diff --git a/tests/integration/test_generation.py b/tests/integration/test_generation.py new file mode 100644 index 0000000..9160502 --- /dev/null +++ b/tests/integration/test_generation.py @@ -0,0 +1,157 @@ +""" +Integration tests for GET /v1/generation. + +These tests exercise the full pipeline: + HTTP request → router validation → GenerationService → JSON serialisation + +The only mock boundary is MetricsRepository.query_metrics. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/generation" + + +def _metric(**kw) -> FakeMetric: + defaults = { + "zone": "ES", + "unit": "MW", + "production_type": "solar", + "data_state": "official", + "datasource": "ENTSO-E", + "valid": True, + "zone_status": "complete", + } + defaults.update(kw) + return FakeMetric(timestamp=_NOW, value=100.0, metadata=defaults) + + +# ── Happy path ──────────────────────────────────────────────────────────────── + + +def test_returns_200_empty_list(client, mock_db) -> None: + """Empty DB produces a 200 with an empty list. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(_URL).status_code == 200 + assert client.get(_URL).json() == [] + + +def test_returns_generation_structure(client, mock_db) -> None: + """A single metric produces a correctly nested Generation object. + + :return: None + :rtype: None + """ + mock_db([_metric()]) + r = client.get(_URL) + assert r.status_code == 200 + body = r.json() + assert body[0]["zone"] == "ES" + assert body[0]["series"][0]["production"][0]["production_type"] == "solar" + + +def test_zone_filter_is_uppercased(client, mock_db) -> None: + """Lowercase zone query param must be uppercased before reaching the service. + + :return: None + :rtype: None + """ + mock_db([_metric(zone="FR")]) + r = client.get(f"{_URL}?zone=fr") + assert r.status_code == 200 + assert r.json()[0]["zone"] == "FR" + + +def test_lat_lon_zone_found(client, mock_db, mock_geo) -> None: + """Valid lat/lon that resolves to a zone returns 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_metric()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_zone_not_found_returns_404(client, mock_db, mock_geo) -> None: + """lat/lon that resolves to no zone must return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +# ── Validation errors ───────────────────────────────────────────────────────── + + +def test_zone_and_lat_together_returns_400(client, mock_db) -> None: + """Providing both zone and lat is rejected with 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_lat_without_lon_returns_400(client, mock_db) -> None: + """Providing lat without lon is rejected with 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?lat=40").status_code == 400 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """Providing start without end is rejected with 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 + + +def test_start_after_end_returns_400(client, mock_db) -> None: + """Providing start > end is rejected with 400. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(f"{_URL}?start=2025-01-02T00:00:00Z&end=2025-01-01T00:00:00Z") + assert r.status_code == 400 + + +def test_invalid_production_type_returns_400(client, mock_db) -> None: + """An unrecognised production_type must be rejected with 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?production_type=unknown").status_code == 400 + + +def test_valid_production_type_passes(client, mock_db) -> None: + """A recognised production_type must be accepted. + + :return: None + :rtype: None + """ + mock_db([_metric(production_type="solar")]) + assert client.get(f"{_URL}?production_type=solar").status_code == 200 diff --git a/tests/integration/test_impact_share.py b/tests/integration/test_impact_share.py new file mode 100644 index 0000000..be71512 --- /dev/null +++ b/tests/integration/test_impact_share.py @@ -0,0 +1,144 @@ +"""Integration tests for GET /v1/impact_share.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/impact-share" + + +def _m(**kw) -> FakeMetric: + d = {"zone": "ES", "unit": "stress-l/kWh", "impact_type": "water", + "scope": "operational", "source": "FR", "valid": "true", "zone_status": "complete"} + d.update(kw) + return FakeMetric(_NOW, 0.8, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(_URL).status_code == 200 + assert client.get(_URL).json() == [] + + +def test_returns_impact_share_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested ImpactShare object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["impact_type"] == "water" + assert body[0]["series"][0]["blocks"][0]["source"] == "FR" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased before service call. + + :return: None + :rtype: None + """ + mock_db([_m(zone="PT")]) + assert client.get(f"{_URL}?zone=pt").json()[0]["zone"] == "PT" + + +def test_lat_lon_found(client, mock_db, mock_geo) -> None: + """Valid coordinates returning a zone produce 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_m()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_not_found(client, mock_db, mock_geo) -> None: + """Coordinates with no matching zone return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_invalid_impact_type_returns_400(client, mock_db) -> None: + """Unknown impact_type must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?impact_type=carbon").status_code == 400 + + +def test_valid_impact_type_passes(client, mock_db) -> None: + """Known impact_type must be accepted. + + :return: None + :rtype: None + """ + mock_db([_m()]) + assert client.get(f"{_URL}?impact_type=water").status_code == 200 + + +def test_invalid_scope_returns_400(client, mock_db) -> None: + """Invalid scope must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?scope=life-cycle").status_code == 400 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 + + +def test_source_zone_and_source_lat_returns_400(client, mock_db) -> None: + """Providing both source_zone and source_lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?source_zone=FR&source_lat=48").status_code == 400 + + +def test_start_and_end_returns_200(client, mock_db) -> None: + """Valid start and end datetimes must return 200. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(f"{_URL}?start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z") + assert r.status_code == 200 diff --git a/tests/integration/test_impacts.py b/tests/integration/test_impacts.py new file mode 100644 index 0000000..afdfd58 --- /dev/null +++ b/tests/integration/test_impacts.py @@ -0,0 +1,147 @@ +"""Integration tests for GET /v1/impacts.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/impacts" + + +def _m(**kw) -> FakeMetric: + d = {"zone": "ES", "unit": "stress-l/kWh", "impact_type": "water", + "scope": "operational", "valid": "true", "zone_status": "complete"} + d.update(kw) + return FakeMetric(_NOW, 1.5, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(_URL).status_code == 200 + assert client.get(_URL).json() == [] + + +def test_returns_impact_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested Impact object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["impact_type"] == "water" + assert body[0]["scope"] == "operational" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased before service call. + + :return: None + :rtype: None + """ + mock_db([_m(zone="PT")]) + assert client.get(f"{_URL}?zone=pt").json()[0]["zone"] == "PT" + + +def test_lat_lon_found(client, mock_db, mock_geo) -> None: + """Valid coordinates returning a zone produce 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_m()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_not_found(client, mock_db, mock_geo) -> None: + """Coordinates with no matching zone return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_invalid_impact_type_returns_400(client, mock_db) -> None: + """Unknown impact_type must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?impact_type=carbon").status_code == 400 + + +def test_valid_impact_type_passes(client, mock_db) -> None: + """Known impact_type must be accepted. + + :return: None + :rtype: None + """ + mock_db([_m()]) + assert client.get(f"{_URL}?impact_type=water").status_code == 200 + + +def test_invalid_scope_returns_400(client, mock_db) -> None: + """Invalid scope must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?scope=political").status_code == 400 + + +def test_aggregate_without_dates_returns_400(client, mock_db) -> None: + """aggregate=true without start/end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?aggregate=true").status_code == 400 + + +def test_aggregate_with_dates_returns_200(client, mock_db) -> None: + """aggregate=true with start/end must return 200. + + :return: None + :rtype: None + """ + mock_db([_m()]) + r = client.get( + f"{_URL}?aggregate=true" + "&start=2025-06-01T00:00:00Z&end=2025-06-02T00:00:00Z" + ) + assert r.status_code == 200 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 diff --git a/tests/integration/test_imports.py b/tests/integration/test_imports.py new file mode 100644 index 0000000..bfb06b5 --- /dev/null +++ b/tests/integration/test_imports.py @@ -0,0 +1,124 @@ +"""Integration tests for GET /v1/imports.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/imports" + + +def _m(**kw) -> FakeMetric: + d = {"zone": "ES", "unit": "MW", "from": "FR", "data_state": "official", + "datasource": "ENTSO-E", "valid": "true", "zone_status": "complete"} + d.update(kw) + return FakeMetric(_NOW, 300.0, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(_URL) + assert r.status_code == 200 + assert r.json() == [] + + +def test_returns_import_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested Import object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["series"][0]["imports"][0]["source"] == "FR" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased before service call. + + :return: None + :rtype: None + """ + mock_db([_m(zone="PT")]) + assert client.get(f"{_URL}?zone=pt").json()[0]["zone"] == "PT" + + +def test_lat_lon_found(client, mock_db, mock_geo) -> None: + """Valid coordinates returning a zone produce 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_m()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_not_found(client, mock_db, mock_geo) -> None: + """Coordinates with no matching zone return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_lat_without_lon_returns_400(client, mock_db) -> None: + """Providing lat without lon must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?lat=40").status_code == 400 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 + + +def test_start_after_end_returns_400(client, mock_db) -> None: + """start > end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(f"{_URL}?start=2025-01-02T00:00:00Z&end=2025-01-01T00:00:00Z") + assert r.status_code == 400 + + +def test_source_zone_and_source_lat_returns_400(client, mock_db) -> None: + """Providing both source_zone and source_lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?source_zone=FR&source_lat=48").status_code == 400 diff --git a/tests/integration/test_load.py b/tests/integration/test_load.py new file mode 100644 index 0000000..41a8673 --- /dev/null +++ b/tests/integration/test_load.py @@ -0,0 +1,114 @@ +"""Integration tests for GET /v1/load.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/load" + + +def _m(**kw) -> FakeMetric: + d = {"zone": "ES", "unit": "MW", "data_state": "official", + "datasource": "ENTSO-E", "valid": True, "zone_status": "complete"} + d.update(kw) + return FakeMetric(_NOW, 5000.0, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 and empty list. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(_URL) + assert r.status_code == 200 + assert r.json() == [] + + +def test_returns_load_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested Load object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["series"][0]["blocks"][0]["data_state"] == "official" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased. + + :return: None + :rtype: None + """ + mock_db([_m(zone="FR")]) + assert client.get(f"{_URL}?zone=fr").json()[0]["zone"] == "FR" + + +def test_lat_lon_found(client, mock_db, mock_geo) -> None: + """Valid coordinates returning a zone produce 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_m()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_not_found(client, mock_db, mock_geo) -> None: + """Coordinates with no matching zone return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_lat_without_lon_returns_400(client, mock_db) -> None: + """Providing lat without lon must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?lat=40").status_code == 400 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """Providing start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 + + +def test_start_after_end_returns_400(client, mock_db) -> None: + """start > end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(f"{_URL}?start=2025-01-02T00:00:00Z&end=2025-01-01T00:00:00Z") + assert r.status_code == 400 diff --git a/tests/integration/test_mix.py b/tests/integration/test_mix.py new file mode 100644 index 0000000..7933fcc --- /dev/null +++ b/tests/integration/test_mix.py @@ -0,0 +1,102 @@ +"""Integration tests for GET /v1/mix.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/mix" + + +def _m(**kw) -> FakeMetric: + d = {"zone": "ES", "unit": "MW", "production_type": "solar", + "data_state": "official", "datasource": "flow_tracing", + "valid": True, "zone_status": "complete"} + d.update(kw) + return FakeMetric(_NOW, 200.0, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(_URL).status_code == 200 + assert client.get(_URL).json() == [] + + +def test_returns_mix_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested Mix object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["series"][0]["production"][0]["production_type"] == "solar" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased before service call. + + :return: None + :rtype: None + """ + mock_db([_m(zone="PT")]) + assert client.get(f"{_URL}?zone=pt").json()[0]["zone"] == "PT" + + +def test_invalid_production_type_returns_400(client, mock_db) -> None: + """Unknown production_type must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?production_type=invalid").status_code == 400 + + +def test_valid_production_type_passes(client, mock_db) -> None: + """Known production_type must be accepted. + + :return: None + :rtype: None + """ + mock_db([_m(production_type="wind_onshore")]) + assert client.get(f"{_URL}?production_type=wind_onshore").status_code == 200 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 + + +def test_start_and_end_returns_200(client, mock_db) -> None: + """Valid start and end datetimes must return 200. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(f"{_URL}?start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z") + assert r.status_code == 200 diff --git a/tests/integration/test_mix_share.py b/tests/integration/test_mix_share.py new file mode 100644 index 0000000..a2a0a5a --- /dev/null +++ b/tests/integration/test_mix_share.py @@ -0,0 +1,123 @@ +"""Integration tests for GET /v1/mix_share.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/mix-share" + + +def _m(**kw) -> FakeMetric: + d = {"zone": "ES", "unit": "%", "source": "FR", + "valid": "true", "zone_status": "complete"} + d.update(kw) + return FakeMetric(_NOW, 20.0, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(_URL).status_code == 200 + assert client.get(_URL).json() == [] + + +def test_returns_mix_share_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested MixShare object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["series"][0]["shares"][0]["origin"] == "FR" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased before service call. + + :return: None + :rtype: None + """ + mock_db([_m(zone="PT")]) + assert client.get(f"{_URL}?zone=pt").json()[0]["zone"] == "PT" + + +def test_lat_lon_found(client, mock_db, mock_geo) -> None: + """Valid coordinates returning a zone produce 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_m()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_not_found(client, mock_db, mock_geo) -> None: + """Coordinates with no matching zone return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_lat_without_lon_returns_400(client, mock_db) -> None: + """Providing lat without lon must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?lat=40").status_code == 400 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 + + +def test_origin_zone_and_origin_lat_returns_400(client, mock_db) -> None: + """Providing both origin_zone and origin_lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?origin_zone=FR&origin_lat=48").status_code == 400 + + +def test_start_and_end_returns_200(client, mock_db) -> None: + """Valid start and end datetimes must return 200. + + :return: None + :rtype: None + """ + mock_db([]) + r = client.get(f"{_URL}?start=2025-01-01T00:00:00Z&end=2025-01-02T00:00:00Z") + assert r.status_code == 200 diff --git a/tests/integration/test_scores.py b/tests/integration/test_scores.py new file mode 100644 index 0000000..811a97a --- /dev/null +++ b/tests/integration/test_scores.py @@ -0,0 +1,126 @@ +"""Integration tests for GET /v1/scores.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from tests.unit.service.helpers import FakeMetric + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_URL = "/v1/green-score" + + +def _m(**kw) -> FakeMetric: + d = {"zone": "ES", "unit": "%", "scope": "operational", + "valid": "true", "zone_status": "complete"} + d.update(kw) + return FakeMetric(_NOW, 75.0, d) + + +def test_200_empty(client, mock_db) -> None: + """Empty DB returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(_URL).status_code == 200 + assert client.get(_URL).json() == [] + + +def test_returns_score_structure(client, mock_db) -> None: + """Valid metric produces a correctly nested GreenScore object. + + :return: None + :rtype: None + """ + mock_db([_m()]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert body[0]["scope"] == "operational" + + +def test_zone_uppercased(client, mock_db) -> None: + """Lowercase zone param must be uppercased before service call. + + :return: None + :rtype: None + """ + mock_db([_m(zone="PT")]) + assert client.get(f"{_URL}?zone=pt").json()[0]["zone"] == "PT" + + +def test_lat_lon_found(client, mock_db, mock_geo) -> None: + """Valid coordinates returning a zone produce 200. + + :return: None + :rtype: None + """ + mock_geo("ES") + mock_db([_m()]) + assert client.get(f"{_URL}?lat=40&lon=-3").status_code == 200 + + +def test_lat_lon_not_found(client, mock_db, mock_geo) -> None: + """Coordinates with no matching zone return 404. + + :return: None + :rtype: None + """ + mock_geo(None) + mock_db([]) + assert client.get(f"{_URL}?lat=0&lon=0").status_code == 404 + + +def test_zone_and_lat_returns_400(client, mock_db) -> None: + """Providing both zone and lat must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?zone=ES&lat=40").status_code == 400 + + +def test_invalid_scope_returns_400(client, mock_db) -> None: + """Unknown scope must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?scope=life-cycle").status_code == 400 + + +def test_aggregate_without_dates_returns_400(client, mock_db) -> None: + """aggregate=true without start/end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?aggregate=true").status_code == 400 + + +def test_aggregate_with_dates_returns_200(client, mock_db) -> None: + """aggregate=true with start/end must return 200. + + :return: None + :rtype: None + """ + mock_db([_m()]) + r = client.get( + f"{_URL}?aggregate=true" + "&start=2025-06-01T00:00:00Z&end=2025-06-02T00:00:00Z" + ) + assert r.status_code == 200 + + +def test_start_without_end_returns_400(client, mock_db) -> None: + """start without end must return 400. + + :return: None + :rtype: None + """ + mock_db([]) + assert client.get(f"{_URL}?start=2025-01-01T00:00:00Z").status_code == 400 diff --git a/tests/integration/test_status.py b/tests/integration/test_status.py new file mode 100644 index 0000000..28ee1e4 --- /dev/null +++ b/tests/integration/test_status.py @@ -0,0 +1,182 @@ +"""Integration tests for GET /v1/status endpoints.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from wattnet.api.routers.v1.status import ( + check_elexon_api, + check_entsoe_api, + check_epias_api, + check_storage_system, +) + +_URL = "/v1/status" + + +@pytest.fixture +def mock_requests_up(monkeypatch): + """Patch requests.get to simulate all external services reachable. + + :param monkeypatch: pytest monkeypatch fixture + """ + mock = MagicMock() + mock.return_value.status_code = 200 + monkeypatch.setattr("wattnet.api.routers.v1.status.requests.get", mock) + + +@pytest.fixture +def mock_requests_down(monkeypatch): + """Patch requests.get to raise RequestException (service unreachable). + + :param monkeypatch: pytest monkeypatch fixture + """ + import requests as _requests + + monkeypatch.setattr( + "wattnet.api.routers.v1.status.requests.get", + MagicMock(side_effect=_requests.RequestException("unreachable")), + ) + + +def test_status_all_up(client, mock_requests_up) -> None: + """When all services are reachable, /status returns all 'up'. + + :return: None + :rtype: None + """ + r = client.get(_URL) + assert r.status_code == 200 + body = r.json() + assert body["storage"] == "up" + assert body["entso-e"] == "up" + assert body["elexon"] == "up" + assert body["epias"] == "up" + + +def test_status_all_down(client, mock_requests_down) -> None: + """When all services are unreachable, /status returns all 'down'. + + :return: None + :rtype: None + """ + r = client.get(_URL) + assert r.status_code == 200 + body = r.json() + assert body["storage"] == "down" + assert body["entso-e"] == "down" + assert body["elexon"] == "down" + assert body["epias"] == "down" + + +def test_status_storage_up(client, mock_requests_up) -> None: + """GET /v1/status/storage returns storage status. + + :return: None + :rtype: None + """ + r = client.get(f"{_URL}/storage") + assert r.status_code == 200 + assert r.json()["storage"] == "up" + + +def test_status_entsoe_up(client, mock_requests_up) -> None: + """GET /v1/status/entso-e returns entso-e status. + + :return: None + :rtype: None + """ + r = client.get(f"{_URL}/entso-e") + assert r.status_code == 200 + assert r.json()["entso-e"] == "up" + + +def test_status_elexon_up(client, mock_requests_up) -> None: + """GET /v1/status/elexon returns elexon status. + + :return: None + :rtype: None + """ + r = client.get(f"{_URL}/elexon") + assert r.status_code == 200 + assert r.json()["elexon"] == "up" + + +def test_status_epias_up(client, mock_requests_up) -> None: + """GET /v1/status/epias returns epias status. + + :return: None + :rtype: None + """ + r = client.get(f"{_URL}/epias") + assert r.status_code == 200 + assert r.json()["epias"] == "up" + + +# ── Missing URL branches ────────────────────────────────────────────────────── + + +def test_check_storage_system_non_200_returns_false(monkeypatch) -> None: + """check_storage_system() must return False when the server returns non-200. + + :return: None + :rtype: None + """ + mock = MagicMock() + mock.return_value.status_code = 503 + monkeypatch.setattr("wattnet.api.routers.v1.status.requests.get", mock) + assert check_storage_system() is False + + +def test_check_storage_system_missing_url_returns_false(monkeypatch) -> None: + """check_storage_system() must return False when storage_db_url is empty. + + :return: None + :rtype: None + """ + monkeypatch.setattr( + "wattnet.api.routers.v1.status.settings", + MagicMock(storage_db_url=""), + ) + assert check_storage_system() is False + + +def test_check_entsoe_api_missing_url_returns_false(monkeypatch) -> None: + """check_entsoe_api() must return False when entsoe_url is empty. + + :return: None + :rtype: None + """ + monkeypatch.setattr( + "wattnet.api.routers.v1.status.settings", + MagicMock(entsoe_url=""), + ) + assert check_entsoe_api() is False + + +def test_check_elexon_api_missing_url_returns_false(monkeypatch) -> None: + """check_elexon_api() must return False when elexon_url is empty. + + :return: None + :rtype: None + """ + monkeypatch.setattr( + "wattnet.api.routers.v1.status.settings", + MagicMock(elexon_url=""), + ) + assert check_elexon_api() is False + + +def test_check_epias_api_missing_url_returns_false(monkeypatch) -> None: + """check_epias_api() must return False when epias_url is empty. + + :return: None + :rtype: None + """ + monkeypatch.setattr( + "wattnet.api.routers.v1.status.settings", + MagicMock(epias_url=""), + ) + assert check_epias_api() is False diff --git a/tests/integration/test_zones.py b/tests/integration/test_zones.py new file mode 100644 index 0000000..af66d53 --- /dev/null +++ b/tests/integration/test_zones.py @@ -0,0 +1,118 @@ +"""Integration tests for GET /v1/zones.""" + +from __future__ import annotations + +import pytest + +_URL = "/v1/zones" + + +@pytest.fixture +def mock_zones(monkeypatch): + """Return a callable that configures what ZoneService.get_zones returns. + + :param monkeypatch: pytest monkeypatch fixture + :return: callable(zones) that sets the return value for get_zones + """ + def _configure(zones): + monkeypatch.setattr( + "wattnet.api.dependencies.zone_service.get_zones", + lambda: zones, + ) + return _configure + + +def test_200_empty(client, mock_zones) -> None: + """Empty zones list returns 200 with empty list. + + :return: None + :rtype: None + """ + mock_zones([]) + r = client.get(_URL) + assert r.status_code == 200 + assert r.json() == [] + + +def test_returns_zone_structure(client, mock_zones) -> None: + """Service result is serialised and returned as JSON list. + + :return: None + :rtype: None + """ + from wattnet.api.models.zone import Zone + + zone = Zone( + zone="ES", + full_name="Spain", + eic_code="10YES-REE------0", + country_code="ESP", + country_name="Spain", + provider="ENTSO-E", + neighbours=["FR", "PT"], + ) + mock_zones([zone]) + body = client.get(_URL).json() + assert body[0]["zone"] == "ES" + assert "FR" in body[0]["neighbours"] + + +def test_get_zones_value_error_returns_500(client, monkeypatch) -> None: + """ValueError from zone service returns HTTP 500 with detail. + + :return: None + :rtype: None + """ + + def _raise() -> None: + raise ValueError("bad zone config") + + monkeypatch.setattr( + "wattnet.api.dependencies.zone_service.get_zones", + _raise, + ) + r = client.get(_URL) + assert r.status_code == 500 + assert "bad zone config" in r.json()["detail"] + + +def test_get_zones_yaml_error_returns_500(client, monkeypatch) -> None: + """yaml.YAMLError from zone service returns HTTP 500. + + :return: None + :rtype: None + """ + import yaml + + def _raise() -> None: + raise yaml.YAMLError("corrupt yaml") + + monkeypatch.setattr( + "wattnet.api.dependencies.zone_service.get_zones", + _raise, + ) + r = client.get(_URL) + assert r.status_code == 500 + + +def test_multiple_zones_returned(client, mock_zones) -> None: + """Multiple zones are all returned in the response. + + :return: None + :rtype: None + """ + from wattnet.api.models.zone import Zone + + zones = [ + Zone(zone="ES", full_name="Spain", eic_code="10YES-REE------0", + country_code="ESP", country_name="Spain", provider="ENTSO-E", + neighbours=["FR"]), + Zone(zone="FR", full_name="France", eic_code="10YFR-RTE------C", + country_code="FRA", country_name="France", provider="ENTSO-E", + neighbours=["ES", "DE"]), + ] + mock_zones(zones) + body = client.get(_URL).json() + assert len(body) == 2 + zone_ids = {z["zone"] for z in body} + assert zone_ids == {"ES", "FR"} diff --git a/tests/unit/service/helpers.py b/tests/unit/service/helpers.py new file mode 100644 index 0000000..3ad5fef --- /dev/null +++ b/tests/unit/service/helpers.py @@ -0,0 +1,58 @@ +"""Shared test doubles for unit tests of the service layer.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Dict, List, Optional + + +@dataclass +class FakeMetric: + """Minimal Metric test double. + + :param timestamp: Metric timestamp + :type timestamp: datetime + :param value: Metric value (None means missing) + :type value: float | None + :param metadata: Metadata dictionary + :type metadata: Dict[str, object] + """ + + timestamp: datetime + value: Optional[float] + metadata: Dict[str, object] + + +class FakeRepo: + """Configurable MetricsRepository stub. + + :param metrics: Metrics returned by every query_metrics call + :type metrics: List[FakeMetric] + """ + + def __init__(self, metrics: List[FakeMetric]) -> None: + """Initialise with a fixed list of metrics. + + :param metrics: Metrics to return + :type metrics: List[FakeMetric] + """ + self._metrics = metrics + + def query_metrics( + self, + metric_name: str, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + labels: Optional[Dict] = None, + ) -> List[FakeMetric]: + """Return the preconfigured metric list, ignoring all filters. + + :param metric_name: Ignored + :param start: Ignored + :param end: Ignored + :param labels: Ignored + :return: Preconfigured metric list + :rtype: List[FakeMetric] + """ + return self._metrics diff --git a/tests/unit/service/test_exports.py b/tests/unit/service/test_exports.py new file mode 100644 index 0000000..3b695f2 --- /dev/null +++ b/tests/unit/service/test_exports.py @@ -0,0 +1,226 @@ +""" +Unit tests for wattnet.api.service.exports module. + +These tests validate: +- Filtering of invalid (None/negative) metric values +- Hierarchical grouping: Export → ExportSeries → ExportBlock +- Separation into series by (valid, zone_status) pairs +- Separation into blocks by (to/destination, data_state, datasource) +- Chronological ordering of values within blocks +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.service.exports import ExportService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _m( + ts: datetime, + value: float | None, + zone: str = "ES", + unit: str = "MW", + to: str = "FR", + data_state: str = "official", + datasource: str = "ENTSO-E", + valid: bool = True, + zone_status: str = "complete", +) -> FakeMetric: + """Build a FakeMetric with export metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Origin zone code + :param unit: Energy unit + :param to: Destination zone label (DB uses 'to') + :param data_state: Data state + :param datasource: Data provider + :param valid: Validity flag + :param zone_status: Zone status + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "zone": zone, + "unit": unit, + "to": to, + "data_state": data_state, + "datasource": datasource, + "valid": valid, + "zone_status": zone_status, + }, + ) + + +# ============================================================ +# get_exports — basic filtering +# ============================================================ + + +def test_get_exports_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = ExportService(metrics_repo=FakeRepo([])) + assert svc.get_exports() == [] + + +def test_get_exports_filters_none_values() -> None: + """Metrics with value=None must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, None), _m(_NOW + timedelta(hours=1), 400.0)] + svc = ExportService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_exports() + + assert len(results) == 1 + assert results[0].series[0].exports[0].values[0][1] == pytest.approx(400.0) + + +def test_get_exports_filters_negative_values() -> None: + """Metrics with negative values must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, -10.0), _m(_NOW + timedelta(hours=1), 300.0)] + svc = ExportService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_exports() + + assert len(results) == 1 + assert results[0].series[0].exports[0].values[0][1] == pytest.approx(300.0) + + +# ============================================================ +# _group_metrics — structure +# ============================================================ + + +def test_get_exports_one_object_per_zone() -> None: + """Metrics for separate origin zones must produce separate Export objects. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, 100.0, zone="ES"), _m(_NOW, 200.0, zone="FR")] + svc = ExportService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_exports() + + assert len(results) == 2 + assert {r.zone for r in results} == {"ES", "FR"} + + +def test_get_exports_separate_blocks_by_destination() -> None: + """Metrics to different destinations must produce separate ExportBlocks. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, to="FR"), + _m(_NOW + timedelta(hours=1), 50.0, to="PT"), + ] + svc = ExportService(metrics_repo=FakeRepo(metrics)) + + blocks = svc.get_exports()[0].series[0].exports + + assert {b.destination for b in blocks} == {"FR", "PT"} + + +def test_get_exports_destination_field_is_populated() -> None: + """The ExportBlock 'destination' field must be populated from the 'to' label. + + :return: None + :rtype: None + """ + svc = ExportService(metrics_repo=FakeRepo([_m(_NOW, 100.0, to="DE")])) + + block = svc.get_exports()[0].series[0].exports[0] + + assert block.destination == "DE" + + +def test_get_exports_separate_series_by_validity() -> None: + """Different (valid, zone_status) pairs must create separate ExportSeries. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, valid=True, zone_status="complete"), + _m(_NOW + timedelta(hours=1), 80.0, valid=False, zone_status="preview"), + ] + svc = ExportService(metrics_repo=FakeRepo(metrics)) + + assert len(svc.get_exports()[0].series) == 2 + + +def test_get_exports_with_destination_filter() -> None: + """Calling get_exports(destination=...) must not raise and returns a list. + + :return: None + :rtype: None + """ + svc = ExportService(metrics_repo=FakeRepo([])) + assert svc.get_exports(destination="FR") == [] + + +def test_get_exports_block_values_sorted_by_timestamp() -> None: + """Values within a block must be sorted ascending by timestamp. + + :return: None + :rtype: None + """ + t0 = _NOW + t2 = _NOW + timedelta(hours=2) + t1 = _NOW + timedelta(hours=1) + + metrics = [_m(t0, 1.0), _m(t2, 3.0), _m(t1, 2.0)] + svc = ExportService(metrics_repo=FakeRepo(metrics)) + + values = svc.get_exports()[0].series[0].exports[0].values + + assert values[0][0] == t0 + assert values[1][0] == t1 + assert values[2][0] == t2 + + +# ============================================================ +# get_exports — filter forwarding +# ============================================================ + + +def test_get_exports_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = ExportService(metrics_repo=FakeRepo([])) + assert svc.get_exports(zone="ES") == [] + + +def test_get_exports_with_destination_filter() -> None: + """destination filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = ExportService(metrics_repo=FakeRepo([])) + assert svc.get_exports(destination="FR") == [] diff --git a/tests/unit/service/test_factors.py b/tests/unit/service/test_factors.py new file mode 100644 index 0000000..0a5abdf --- /dev/null +++ b/tests/unit/service/test_factors.py @@ -0,0 +1,295 @@ +""" +Unit tests for wattnet.api.service.factors module. + +These tests validate: +- Filtering of invalid (None/negative) metric values +- Series vs aggregate mode routing +- Correct seven-field grouping key (factor_type, production_type, scope, unit, + source, year, source_link) +- Aggregation produces FactorAggregate with aggregation_method field +- Time-series grouping produces Factor with FactorSeries +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Optional + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.models.factor import Factor, FactorAggregate +from wattnet.api.service.factors import FactorService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_END = _NOW + timedelta(hours=2) + + +def _m( + ts: datetime, + value: Optional[float], + factor_type: str = "carbon", + production_type: str = "solar", + scope: str = "operational", + unit: str = "gCO2/kWh", + source: str = "IPCC", + year: int = 2023, + source_link: str = "https://ipcc.ch", +) -> FakeMetric: + """Build a FakeMetric with factor metadata. + + :param ts: Timestamp + :param value: Metric value + :param factor_type: Factor type + :param production_type: Energy source type + :param scope: Factor scope + :param unit: Factor unit + :param source: Data source name + :param year: Reference year + :param source_link: Link to data source + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "factor_type": factor_type, + "production_type": production_type, + "scope": scope, + "unit": unit, + "source": source, + "year": year, + "source_link": source_link, + }, + ) + + +# ============================================================ +# get_factors — filtering +# ============================================================ + + +def test_get_factors_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = FactorService(metrics_repo=FakeRepo([])) + assert svc.get_factors() == [] + + +def test_get_factors_filters_none_values() -> None: + """Metrics with value=None must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, None), _m(_NOW + timedelta(hours=1), 25.0)] + svc = FactorService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_factors() + + assert len(results) == 1 + + +def test_get_factors_filters_negative_values() -> None: + """Metrics with negative values must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, -1.0), _m(_NOW + timedelta(hours=1), 30.0)] + svc = FactorService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_factors() + + assert len(results) == 1 + + +# ============================================================ +# get_factors — series vs aggregate routing +# ============================================================ + + +def test_get_factors_returns_factor_series_by_default() -> None: + """Default (aggregate=False) must return Factor time-series objects. + + :return: None + :rtype: None + """ + svc = FactorService(metrics_repo=FakeRepo([_m(_NOW, 25.0)])) + + results = svc.get_factors(aggregate=False) + + assert isinstance(results[0], Factor) + + +def test_get_factors_returns_aggregate_when_requested() -> None: + """aggregate=True with start/end must return FactorAggregate objects. + + :return: None + :rtype: None + """ + svc = FactorService(metrics_repo=FakeRepo([_m(_NOW, 25.0)])) + + results = svc.get_factors(aggregate=True, start=_NOW, end=_END) + + assert isinstance(results[0], FactorAggregate) + + +def test_get_factors_aggregate_without_dates_is_series() -> None: + """aggregate=True without start/end must fall back to series mode. + + :return: None + :rtype: None + """ + svc = FactorService(metrics_repo=FakeRepo([_m(_NOW, 25.0)])) + + results = svc.get_factors(aggregate=True) + + assert isinstance(results[0], Factor) + + +# ============================================================ +# _group_metrics_series — grouping key +# ============================================================ + + +def test_get_factors_separate_factors_by_production_type() -> None: + """Metrics with different production types must produce separate Factor objects. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 25.0, production_type="solar"), + _m(_NOW, 40.0, production_type="coal"), + ] + svc = FactorService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_factors() + + assert len(results) == 2 + types = {r.production_type for r in results} + assert types == {"solar", "coal"} + + +def test_get_factors_separate_factors_by_scope() -> None: + """Metrics with different scopes must produce separate Factor objects. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 25.0, scope="operational"), + _m(_NOW, 80.0, scope="life-cycle"), + ] + svc = FactorService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_factors() + + assert len(results) == 2 + scopes = {r.scope for r in results} + assert scopes == {"operational", "life-cycle"} + + +# ============================================================ +# _aggregate_metrics — key fields +# ============================================================ + + +def test_factor_aggregate_has_aggregation_method() -> None: + """FactorAggregate must always carry aggregation_method='time-weighted-average'. + + :return: None + :rtype: None + """ + svc = FactorService(metrics_repo=FakeRepo([_m(_NOW, 25.0)])) + + result = svc.get_factors(aggregate=True, start=_NOW, end=_END)[0] + + assert result.aggregation_method == "time-weighted-average" + + +def test_aggregate_metrics_returns_empty_list_for_empty_input() -> None: + """_aggregate_metrics([]) must return an empty list. + + :return: None + :rtype: None + """ + svc = FactorService(metrics_repo=FakeRepo([])) + assert svc._aggregate_metrics([], _NOW, _END) == [] + + +def test_group_metrics_series_returns_empty_list_for_empty_input() -> None: + """_group_metrics_series([]) must return an empty list. + + :return: None + :rtype: None + """ + svc = FactorService(metrics_repo=FakeRepo([])) + assert svc._group_metrics_series([]) == [] + + +def test_factor_aggregate_preserves_metadata_fields() -> None: + """FactorAggregate must preserve factor_type, production_type, scope, unit. + + :return: None + :rtype: None + """ + svc = FactorService( + metrics_repo=FakeRepo( + [ + _m( + _NOW, + 25.0, + factor_type="carbon", + production_type="solar", + scope="operational", + unit="gCO2/kWh", + ) + ] + ) + ) + + result = svc.get_factors(aggregate=True, start=_NOW, end=_END)[0] + + assert result.factor_type == "carbon" + assert result.production_type == "solar" + assert result.scope == "operational" + assert result.unit == "gCO2/kWh" + + +# ============================================================ +# get_factors — filter forwarding +# ============================================================ + + +def test_get_factors_with_production_type_filter() -> None: + """production_type filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = FactorService(metrics_repo=FakeRepo([])) + assert svc.get_factors(production_type="solar") == [] + + +def test_get_factors_with_factor_type_filter() -> None: + """factor_type filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = FactorService(metrics_repo=FakeRepo([])) + assert svc.get_factors(factor_type="carbon") == [] + + +def test_get_factors_with_scope_filter() -> None: + """scope filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = FactorService(metrics_repo=FakeRepo([])) + assert svc.get_factors(scope="operational") == [] diff --git a/tests/unit/service/test_flow_share.py b/tests/unit/service/test_flow_share.py new file mode 100644 index 0000000..d0c6871 --- /dev/null +++ b/tests/unit/service/test_flow_share.py @@ -0,0 +1,214 @@ +""" +Unit tests for wattnet.api.service.flow_share module. + +These tests validate: +- Empty repository returns empty list +- Hierarchical grouping: FlowShare → FlowShareSeries → FlowShareBlock +- Separation into series by (valid, zone_status) pairs +- Separation into blocks by target (destination) zone +- Chronological ordering of values within blocks +- Correct unit is always '%' +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.service.flow_share import FlowShareService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _m( + ts: datetime, + value: float, + zone: str = "ES", + target: str = "FR", + valid: bool = True, + zone_status: str = "complete", +) -> FakeMetric: + """Build a FakeMetric with flow share metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Origin zone code + :param target: Destination zone code (DB label is 'target') + :param valid: Validity flag + :param zone_status: Zone status + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "zone": zone, + "target": target, + "valid": valid, + "zone_status": zone_status, + }, + ) + + +# ============================================================ +# get_flow_share — basic +# ============================================================ + + +def test_get_flow_share_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = FlowShareService(metrics_repo=FakeRepo([])) + assert svc.get_flow_share() == [] + + +def test_get_flow_share_unit_is_percent() -> None: + """The unit field on every FlowShare must always be '%'. + + :return: None + :rtype: None + """ + svc = FlowShareService(metrics_repo=FakeRepo([_m(_NOW, 0.5)])) + assert svc.get_flow_share()[0].unit == "%" + + +# ============================================================ +# _group_metrics — structure +# ============================================================ + + +def test_get_flow_share_one_object_per_zone() -> None: + """Metrics for separate origin zones must produce separate FlowShare objects. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, 0.3, zone="ES"), _m(_NOW, 0.4, zone="FR")] + svc = FlowShareService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_flow_share() + + assert len(results) == 2 + assert {r.zone for r in results} == {"ES", "FR"} + + +def test_get_flow_share_separate_blocks_by_target() -> None: + """Metrics with different target zones must produce separate FlowShareBlocks. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 0.3, target="FR"), + _m(_NOW + timedelta(hours=1), 0.2, target="PT"), + ] + svc = FlowShareService(metrics_repo=FakeRepo(metrics)) + + blocks = svc.get_flow_share()[0].series[0].flows + + assert {b.destination for b in blocks} == {"FR", "PT"} + + +def test_get_flow_share_destination_mapped_from_target_label() -> None: + """The FlowShareBlock 'destination' field must be populated from the 'target' label. + + :return: None + :rtype: None + """ + svc = FlowShareService(metrics_repo=FakeRepo([_m(_NOW, 0.5, target="DE")])) + + block = svc.get_flow_share()[0].series[0].flows[0] + + assert block.destination == "DE" + + +def test_get_flow_share_separate_series_by_validity() -> None: + """Different (valid, zone_status) pairs must create separate FlowShareSeries. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 0.3, valid=True, zone_status="complete"), + _m(_NOW + timedelta(hours=1), 0.2, valid=False, zone_status="preview"), + ] + svc = FlowShareService(metrics_repo=FakeRepo(metrics)) + + assert len(svc.get_flow_share()[0].series) == 2 + + +def test_get_flow_share_with_destination_filter() -> None: + """Calling get_flow_share(destination=...) must not raise and returns a list. + + :return: None + :rtype: None + """ + svc = FlowShareService(metrics_repo=FakeRepo([])) + assert svc.get_flow_share(destination="FR") == [] + + +def test_get_flow_share_block_values_sorted_by_timestamp() -> None: + """Values within a block must be sorted ascending by timestamp. + + :return: None + :rtype: None + """ + t0 = _NOW + t2 = _NOW + timedelta(hours=2) + t1 = _NOW + timedelta(hours=1) + + metrics = [_m(t0, 0.1), _m(t2, 0.3), _m(t1, 0.2)] + svc = FlowShareService(metrics_repo=FakeRepo(metrics)) + + values = svc.get_flow_share()[0].series[0].flows[0].values + + assert values[0][0] == t0 + assert values[1][0] == t1 + assert values[2][0] == t2 + + +# ============================================================ +# get_flow_share — filter forwarding +# ============================================================ + + +def test_get_flow_share_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = FlowShareService(metrics_repo=FakeRepo([])) + assert svc.get_flow_share(zone="ES") == [] + + +def test_get_flow_share_with_destination_filter() -> None: + """destination filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = FlowShareService(metrics_repo=FakeRepo([])) + assert svc.get_flow_share(destination="FR") == [] + + +def test_get_flow_share_none_target_falls_back_to_unknown() -> None: + """A metric missing the 'target' key must produce destination='unknown', not crash. + + :return: None + :rtype: None + """ + metric = FakeMetric( + timestamp=_NOW, + value=0.5, + metadata={"zone": "ES", "valid": True, "zone_status": "complete"}, + ) + svc = FlowShareService(metrics_repo=FakeRepo([metric])) + + block = svc.get_flow_share()[0].series[0].flows[0] + + assert block.destination == "unknown" diff --git a/tests/unit/service/test_footprint_share.py b/tests/unit/service/test_footprint_share.py new file mode 100644 index 0000000..a2a5677 --- /dev/null +++ b/tests/unit/service/test_footprint_share.py @@ -0,0 +1,206 @@ +""" +Unit tests for wattnet.api.service.footprint_share module. + +These tests validate: +- Empty repository returns empty list +- Hierarchical grouping: FootprintShare → FootprintShareSeries → FootprintShareBlock +- Separation into series by (valid, zone_status) pairs +- Separation into blocks by source zone, with None → 'unknown' +- Chronological ordering of values within blocks +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.service.footprint_share import FootprintShareService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _m( + ts: datetime, + value: float, + zone: str = "ES", + source: str = "FR", + footprint_type: str = "carbon", + scope: str = "operational", + unit: str = "gCO2/kWh", + valid: bool = True, + zone_status: str = "complete", +) -> FakeMetric: + """Build a FakeMetric with footprint share metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Destination zone code + :param source: Origin zone code + :param footprint_type: Footprint type + :param scope: Footprint scope + :param unit: Footprint unit + :param valid: Validity flag + :param zone_status: Zone status + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "zone": zone, + "source": source, + "footprint_type": footprint_type, + "scope": scope, + "unit": unit, + "valid": valid, + "zone_status": zone_status, + }, + ) + + +# ============================================================ +# get_footprint_share — basic +# ============================================================ + + +def test_get_footprint_share_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = FootprintShareService(metrics_repo=FakeRepo([])) + assert svc.get_footprint_share() == [] + + +# ============================================================ +# _group_metrics — structure +# ============================================================ + + +def test_get_footprint_share_one_object_per_zone() -> None: + """Metrics for separate zones must produce separate FootprintShare objects. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, 120.0, zone="ES"), _m(_NOW, 80.0, zone="FR")] + svc = FootprintShareService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_footprint_share() + + assert len(results) == 2 + assert {r.zone for r in results} == {"ES", "FR"} + + +def test_get_footprint_share_separate_blocks_by_source() -> None: + """Metrics with different source zones must produce separate blocks. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 120.0, source="FR"), + _m(_NOW + timedelta(hours=1), 60.0, source="PT"), + ] + svc = FootprintShareService(metrics_repo=FakeRepo(metrics)) + + blocks = svc.get_footprint_share()[0].series[0].blocks + + assert {b.source for b in blocks} == {"FR", "PT"} + + +def test_get_footprint_share_none_source_becomes_unknown() -> None: + """A metric with source=None must be assigned source='unknown'. + + :return: None + :rtype: None + """ + metric = _m(_NOW, 0.5) + metric.metadata["source"] = None + svc = FootprintShareService(metrics_repo=FakeRepo([metric])) + + block = svc.get_footprint_share()[0].series[0].blocks[0] + + assert block.source == "unknown" + + +def test_get_footprint_share_separate_series_by_validity() -> None: + """Different (valid, zone_status) pairs must create separate series. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 120.0, valid=True, zone_status="complete"), + _m(_NOW + timedelta(hours=1), 80.0, valid=False, zone_status="preview"), + ] + svc = FootprintShareService(metrics_repo=FakeRepo(metrics)) + + assert len(svc.get_footprint_share()[0].series) == 2 + + +def test_get_footprint_share_with_source_filter() -> None: + """Calling get_footprint_share(source=...) must not raise and returns a list. + + :return: None + :rtype: None + """ + svc = FootprintShareService(metrics_repo=FakeRepo([])) + assert svc.get_footprint_share(source="FR") == [] + + +def test_get_footprint_share_with_footprint_type_filter() -> None: + """Calling get_footprint_share(footprint_type=...) must not raise and returns a list. + + :return: None + :rtype: None + """ + svc = FootprintShareService(metrics_repo=FakeRepo([])) + assert svc.get_footprint_share(footprint_type="carbon") == [] + + +def test_get_footprint_share_block_values_sorted_by_timestamp() -> None: + """Values within a block must be sorted ascending by timestamp. + + :return: None + :rtype: None + """ + t0 = _NOW + t2 = _NOW + timedelta(hours=2) + t1 = _NOW + timedelta(hours=1) + + metrics = [_m(t0, 1.0), _m(t2, 3.0), _m(t1, 2.0)] + svc = FootprintShareService(metrics_repo=FakeRepo(metrics)) + + values = svc.get_footprint_share()[0].series[0].blocks[0].values + + assert values[0][0] == t0 + assert values[1][0] == t1 + assert values[2][0] == t2 + + +# ============================================================ +# get_footprint_share — filter forwarding +# ============================================================ + + +def test_get_footprint_share_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = FootprintShareService(metrics_repo=FakeRepo([])) + assert svc.get_footprint_share(zone="ES") == [] + + +def test_get_footprint_share_with_scope_filter() -> None: + """scope filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = FootprintShareService(metrics_repo=FakeRepo([])) + assert svc.get_footprint_share(scope="operational") == [] diff --git a/tests/unit/service/test_footprints.py b/tests/unit/service/test_footprints.py new file mode 100644 index 0000000..a13edc0 --- /dev/null +++ b/tests/unit/service/test_footprints.py @@ -0,0 +1,302 @@ +""" +Unit tests for wattnet.api.service.footprints module. + +These tests validate: +- Filtering of invalid (None/negative) metric values +- Series vs aggregate mode routing +- Aggregation: zone_status priority resolution, valid flag, coverage +- Time-series grouping by (footprint_type, scope, zone, unit) and validity subgroups +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Optional + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.models.footprint import Footprint, FootprintAggregate +from wattnet.api.service.footprints import FootprintService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_END = _NOW + timedelta(hours=2) + + +def _m( + ts: datetime, + value: Optional[float], + zone: str = "ES", + footprint_type: str = "carbon", + scope: str = "operational", + unit: str = "gCO2/kWh", + valid: str = "true", + zone_status: str = "complete", +) -> FakeMetric: + """Build a FakeMetric with footprint metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Zone code + :param footprint_type: Footprint type + :param scope: Footprint scope + :param unit: Footprint unit + :param valid: Validity string ('true'/'false') + :param zone_status: Zone status + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "zone": zone, + "footprint_type": footprint_type, + "scope": scope, + "unit": unit, + "valid": valid, + "zone_status": zone_status, + }, + ) + + +# ============================================================ +# get_footprints — filtering +# ============================================================ + + +def test_get_footprints_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([])) + assert svc.get_footprints() == [] + + +def test_get_footprints_filters_none_values() -> None: + """Metrics with value=None must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, None), _m(_NOW + timedelta(hours=1), 200.0)] + svc = FootprintService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_footprints() + + assert len(results) == 1 + + +def test_get_footprints_filters_negative_values() -> None: + """Metrics with negative values must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, -1.0), _m(_NOW + timedelta(hours=1), 150.0)] + svc = FootprintService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_footprints() + + assert len(results) == 1 + + +# ============================================================ +# get_footprints — series vs aggregate routing +# ============================================================ + + +def test_get_footprints_returns_footprint_series_by_default() -> None: + """Default (aggregate=False) must return Footprint time-series objects. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([_m(_NOW, 100.0)])) + + results = svc.get_footprints(aggregate=False) + + assert isinstance(results[0], Footprint) + + +def test_get_footprints_returns_aggregate_when_requested() -> None: + """aggregate=True with start/end must return FootprintAggregate objects. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([_m(_NOW, 100.0)])) + + results = svc.get_footprints(aggregate=True, start=_NOW, end=_END) + + assert isinstance(results[0], FootprintAggregate) + + +def test_get_footprints_series_without_start_end_is_series() -> None: + """aggregate=True but missing start/end must fall back to series mode. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([_m(_NOW, 100.0)])) + + results = svc.get_footprints(aggregate=True) + + assert isinstance(results[0], Footprint) + + +# ============================================================ +# _aggregate_metrics — zone_status / valid / coverage +# ============================================================ + + +def test_footprint_aggregate_zone_status_picks_lowest_priority() -> None: + """Mixed 'complete'/'preview' must resolve to 'preview'. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, zone_status="complete"), + _m(_NOW + timedelta(hours=1), 100.0, zone_status="preview"), + ] + svc = FootprintService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_footprints(aggregate=True, start=_NOW, end=_END) + + assert results[0].zone_status == "preview" + + +def test_footprint_aggregate_valid_false_when_any_invalid() -> None: + """Aggregate is invalid when at least one metric has valid='false'. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, valid="true"), + _m(_NOW + timedelta(hours=1), 100.0, valid="false"), + ] + svc = FootprintService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_footprints(aggregate=True, start=_NOW, end=_END) + + assert results[0].valid is False + + +def test_footprint_aggregate_coverage_global() -> None: + """use_global=True must produce coverage='global'. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([_m(_NOW, 100.0)])) + + results = svc.get_footprints(aggregate=True, start=_NOW, end=_END, use_global=True) + + assert results[0].coverage == "global" + + +def test_footprint_aggregate_coverage_local() -> None: + """use_global=False must produce coverage='local'. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([_m(_NOW, 100.0)])) + + results = svc.get_footprints(aggregate=True, start=_NOW, end=_END, use_global=False) + + assert results[0].coverage == "local" + + +# ============================================================ +# _group_metrics_series — series grouping +# ============================================================ + + +def test_footprint_series_separate_zones() -> None: + """Metrics for different zones must produce separate Footprint objects. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, 100.0, zone="ES"), _m(_NOW, 80.0, zone="FR")] + svc = FootprintService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_footprints(aggregate=False) + + assert len(results) == 2 + assert {r.zone for r in results} == {"ES", "FR"} + + +def test_aggregate_metrics_returns_empty_list_for_empty_input() -> None: + """_aggregate_metrics([]) must return an empty list. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([])) + assert svc._aggregate_metrics([], _NOW, _END, True) == [] + + +def test_group_metrics_series_returns_empty_list_for_empty_input() -> None: + """_group_metrics_series([]) must return an empty list. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([])) + assert svc._group_metrics_series([], True) == [] + + +def test_footprint_series_groups_by_validity_subgroups() -> None: + """Metrics with different valid/zone_status must produce separate series. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, valid="true", zone_status="complete"), + _m(_NOW + timedelta(hours=1), 80.0, valid="false", zone_status="preview"), + ] + svc = FootprintService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_footprints(aggregate=False) + + assert len(results[0].series) == 2 + + +# ============================================================ +# get_footprints — filter forwarding +# ============================================================ + + +def test_get_footprints_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([])) + assert svc.get_footprints(zone="ES") == [] + + +def test_get_footprints_with_footprint_type_filter() -> None: + """footprint_type filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([])) + assert svc.get_footprints(footprint_type="carbon") == [] + + +def test_get_footprints_with_scope_filter() -> None: + """scope filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = FootprintService(metrics_repo=FakeRepo([])) + assert svc.get_footprints(scope="operational") == [] diff --git a/tests/unit/service/test_generation.py b/tests/unit/service/test_generation.py new file mode 100644 index 0000000..fc3a06a --- /dev/null +++ b/tests/unit/service/test_generation.py @@ -0,0 +1,228 @@ +""" +Unit tests for wattnet.api.service.generation module. + +These tests validate: +- Filtering of invalid (None/negative) metric values +- Hierarchical grouping: Generation → GenerationSeries → ProductionBlock +- Separation into series by (valid, zone_status) pairs +- Separation into blocks by (production_type, data_state, datasource) +- Chronological ordering of values within blocks +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.service.generation import GenerationService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _m( + ts: datetime, + value: float | None, + zone: str = "ES", + unit: str = "MW", + production_type: str = "solar", + data_state: str = "official", + datasource: str = "ENTSO-E", + valid: bool = True, + zone_status: str = "complete", +) -> FakeMetric: + """Build a FakeMetric with generation metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Zone code + :param unit: Energy unit + :param production_type: Energy source type + :param data_state: Data state + :param datasource: Data provider + :param valid: Validity flag + :param zone_status: Zone status + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "zone": zone, + "unit": unit, + "production_type": production_type, + "data_state": data_state, + "datasource": datasource, + "valid": valid, + "zone_status": zone_status, + }, + ) + + +# ============================================================ +# get_generation — basic filtering +# ============================================================ + + +def test_get_generation_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = GenerationService(metrics_repo=FakeRepo([])) + assert svc.get_generation() == [] + + +def test_get_generation_filters_none_values() -> None: + """Metrics with value=None must be excluded. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, None), + _m(_NOW + timedelta(hours=1), 100.0), + ] + svc = GenerationService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_generation() + + assert len(results) == 1 + assert results[0].series[0].production[0].values[0][1] == pytest.approx(100.0) + + +def test_get_generation_filters_negative_values() -> None: + """Metrics with negative values must be excluded. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, -50.0), + _m(_NOW + timedelta(hours=1), 200.0), + ] + svc = GenerationService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_generation() + + assert len(results) == 1 + assert results[0].series[0].production[0].values[0][1] == pytest.approx(200.0) + + +def test_get_generation_returns_empty_when_all_filtered() -> None: + """All-invalid metrics must produce no Generation objects. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, None), _m(_NOW, -1.0)] + svc = GenerationService(metrics_repo=FakeRepo(metrics)) + assert svc.get_generation() == [] + + +# ============================================================ +# _group_metrics — structure +# ============================================================ + + +def test_get_generation_one_object_per_zone() -> None: + """Metrics for separate zones must produce separate Generation objects. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, zone="ES"), + _m(_NOW, 200.0, zone="FR"), + ] + svc = GenerationService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_generation() + + assert len(results) == 2 + zones = {r.zone for r in results} + assert zones == {"ES", "FR"} + + +def test_get_generation_separate_series_by_validity() -> None: + """Different (valid, zone_status) pairs must create separate GenerationSeries. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, valid=True, zone_status="complete"), + _m(_NOW + timedelta(hours=1), 80.0, valid=False, zone_status="preview"), + ] + svc = GenerationService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_generation() + + assert len(results[0].series) == 2 + + +def test_get_generation_separate_blocks_by_production_type() -> None: + """Different production types in the same series must produce separate blocks. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, production_type="solar"), + _m(_NOW, 50.0, production_type="wind_onshore"), + ] + svc = GenerationService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_generation() + blocks = results[0].series[0].production + + types = {b.production_type for b in blocks} + assert types == {"solar", "wind_onshore"} + + +def test_get_generation_block_values_sorted_by_timestamp() -> None: + """Values within a block must be sorted ascending by timestamp. + + :return: None + :rtype: None + """ + t0 = _NOW + t1 = _NOW + timedelta(hours=2) + t2 = _NOW + timedelta(hours=1) + + metrics = [_m(t0, 1.0), _m(t1, 3.0), _m(t2, 2.0)] + svc = GenerationService(metrics_repo=FakeRepo(metrics)) + + values = svc.get_generation()[0].series[0].production[0].values + + assert values[0][0] == t0 + assert values[1][0] == t2 + assert values[2][0] == t1 + + +# ============================================================ +# get_generation — filter forwarding +# ============================================================ + + +def test_get_generation_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = GenerationService(metrics_repo=FakeRepo([])) + assert svc.get_generation(zone="ES") == [] + + +def test_get_generation_with_production_type_filter() -> None: + """production_type filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = GenerationService(metrics_repo=FakeRepo([])) + assert svc.get_generation(production_type="solar") == [] diff --git a/tests/unit/service/test_impact_share.py b/tests/unit/service/test_impact_share.py new file mode 100644 index 0000000..993ef87 --- /dev/null +++ b/tests/unit/service/test_impact_share.py @@ -0,0 +1,345 @@ +""" +Unit tests for wattnet.api.service.impact_share module. + +These tests validate: +- Routing by impact_type in get_impact_share +- Empty result when no metrics are found +- Correct nesting of ImpactShare → ImpactShareSeries → ImpactShareBlock +- Fallback to 'unknown' for None source values +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Optional + +from wattnet.api.models.impact_share import ImpactShare +from wattnet.api.service.impact_share import ImpactShareService + +# ============================================================ +# Test doubles +# ============================================================ + + +@dataclass +class FakeMetric: + """Minimal Metric test double. + + :param timestamp: Timestamp of the metric + :type timestamp: datetime + :param value: Metric value + :type value: float | None + :param metadata: Metadata dictionary + :type metadata: Dict[str, object] + """ + + timestamp: datetime + value: Optional[float] + metadata: Dict[str, object] + + +class FakeRepo: + """Configurable MetricsRepository stub. + + :param metrics: Metrics to return from query_metrics + :type metrics: List[FakeMetric] + """ + + def __init__(self, metrics: List[FakeMetric]) -> None: + """Initialise with a fixed list of metrics. + + :param metrics: Metrics returned by query_metrics + :type metrics: List[FakeMetric] + """ + self._metrics = metrics + + def query_metrics( + self, + metric_name: str, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + labels: Optional[Dict] = None, + ) -> List[FakeMetric]: + """Return preconfigured metrics regardless of filters. + + :param metric_name: Ignored + :param start: Ignored + :param end: Ignored + :param labels: Ignored + :return: Preconfigured metric list + :rtype: List[FakeMetric] + """ + return self._metrics + + +# ============================================================ +# Helpers +# ============================================================ + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _make_share_metric( + ts: datetime, + value: float, + zone: str = "ES", + source: str = "FR", + scope: str = "operational", + valid: bool = True, + zone_status: str = "complete", + unit: str = "stress-l/kWh", +) -> FakeMetric: + """Return a FakeMetric with impact-share metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Destination zone + :param source: Origin zone + :param scope: Scope + :param valid: Validity flag + :param zone_status: Zone status + :param unit: Unit string + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "impact_type": "water", + "zone": zone, + "source": source, + "scope": scope, + "valid": valid, + "zone_status": zone_status, + "unit": unit, + }, + ) + + +# ============================================================ +# get_impact_share — routing by impact_type +# ============================================================ + + +def test_get_impact_share_none_type_returns_water_results() -> None: + """impact_type=None should include water results. + + :return: None + :rtype: None + """ + metric = _make_share_metric(_NOW, 0.5) + svc = ImpactShareService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impact_share(impact_type=None) + + assert len(results) == 1 + + +def test_get_impact_share_water_type_returns_results() -> None: + """impact_type='water' should include water results. + + :return: None + :rtype: None + """ + metric = _make_share_metric(_NOW, 0.5) + svc = ImpactShareService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impact_share(impact_type="water") + + assert len(results) == 1 + + +def test_get_impact_share_unknown_type_returns_empty() -> None: + """Unsupported impact_type should produce an empty list. + + :return: None + :rtype: None + """ + metric = _make_share_metric(_NOW, 0.5) + svc = ImpactShareService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impact_share(impact_type="carbon") + + assert results == [] + + +# ============================================================ +# _get_water_impact_share — empty repo +# ============================================================ + + +def test_get_impact_share_returns_empty_when_no_metrics() -> None: + """Empty repository should produce an empty result list. + + :return: None + :rtype: None + """ + svc = ImpactShareService(metrics_repo=FakeRepo([])) + + results = svc.get_impact_share() + + assert results == [] + + +def test_get_impact_share_with_scope_filter() -> None: + """Calling get_impact_share(scope=...) must not raise and returns a list. + + :return: None + :rtype: None + """ + svc = ImpactShareService(metrics_repo=FakeRepo([])) + assert svc.get_impact_share(scope="operational") == [] + + +def test_get_impact_share_with_source_filter() -> None: + """Calling get_impact_share(source=...) must not raise and returns a list. + + :return: None + :rtype: None + """ + svc = ImpactShareService(metrics_repo=FakeRepo([])) + assert svc.get_impact_share(source="FR") == [] + + +# ============================================================ +# _group_metrics — structure +# ============================================================ + + +def test_group_metrics_creates_one_share_per_zone() -> None: + """Metrics for separate zones must produce separate ImpactShare objects. + + :return: None + :rtype: None + """ + metrics = [ + _make_share_metric(_NOW, 0.3, zone="ES"), + _make_share_metric(_NOW, 0.2, zone="FR"), + ] + svc = ImpactShareService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impact_share() + + assert len(results) == 2 + zones = {r.zone for r in results} + assert zones == {"ES", "FR"} + + +def test_group_metrics_creates_one_block_per_source() -> None: + """Metrics with different sources in the same zone must produce separate blocks. + + :return: None + :rtype: None + """ + metrics = [ + _make_share_metric(_NOW, 0.3, zone="ES", source="FR"), + _make_share_metric(_NOW + timedelta(hours=1), 0.2, zone="ES", source="PT"), + ] + svc = ImpactShareService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impact_share() + + assert len(results) == 1 + blocks = results[0].series[0].blocks + sources = {b.source for b in blocks} + assert sources == {"FR", "PT"} + + +def test_group_metrics_values_are_sorted_by_timestamp() -> None: + """Values within a block must be sorted ascending by timestamp. + + :return: None + :rtype: None + """ + t0 = _NOW + t1 = _NOW + timedelta(hours=2) + t2 = _NOW + timedelta(hours=1) + + metrics = [ + _make_share_metric(t0, 0.1), + _make_share_metric(t1, 0.3), + _make_share_metric(t2, 0.2), + ] + svc = ImpactShareService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impact_share() + values = results[0].series[0].blocks[0].values + + assert values[0][0] == t0 + assert values[1][0] == t2 + assert values[2][0] == t1 + + +def test_group_metrics_none_source_becomes_unknown() -> None: + """A metric with source=None must be assigned source='unknown'. + + :return: None + :rtype: None + """ + metric = _make_share_metric(_NOW, 0.5, source=None) # type: ignore[arg-type] + metric.metadata["source"] = None + svc = ImpactShareService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impact_share() + + assert results[0].series[0].blocks[0].source == "unknown" + + +def test_group_metrics_separate_series_by_validity() -> None: + """Metrics with different valid/zone_status must produce separate series. + + :return: None + :rtype: None + """ + metrics = [ + _make_share_metric(_NOW, 0.4, valid=True, zone_status="complete"), + _make_share_metric( + _NOW + timedelta(hours=1), 0.2, valid=False, zone_status="preview" + ), + ] + svc = ImpactShareService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impact_share() + + assert len(results[0].series) == 2 + + +def test_group_metrics_result_is_impact_share_instance() -> None: + """Returned objects must be ImpactShare instances. + + :return: None + :rtype: None + """ + metric = _make_share_metric(_NOW, 0.5) + svc = ImpactShareService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impact_share() + + assert isinstance(results[0], ImpactShare) + + +# ============================================================ +# get_impact_share — filter forwarding +# ============================================================ + + +def test_get_impact_share_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = ImpactShareService(metrics_repo=FakeRepo([])) + assert svc.get_impact_share(zone="ES") == [] + + +def test_get_impact_share_with_scope_filter() -> None: + """scope filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = ImpactShareService(metrics_repo=FakeRepo([])) + assert svc.get_impact_share(scope="operational") == [] diff --git a/tests/unit/service/test_impacts.py b/tests/unit/service/test_impacts.py new file mode 100644 index 0000000..c3d6e17 --- /dev/null +++ b/tests/unit/service/test_impacts.py @@ -0,0 +1,429 @@ +""" +Unit tests for wattnet.api.service.impacts module. + +These tests validate: +- Routing by impact_type in get_impacts +- Filtering of invalid/negative metric values +- Aggregation logic and zone_status priority resolution +- Time-series grouping by validity subgroups +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Dict, List, Optional + +import pytest + +from wattnet.api.models.impact import ImpactAggregate +from wattnet.api.service.impacts import ImpactService + +# ============================================================ +# Test doubles +# ============================================================ + + +@dataclass +class FakeMetric: + """Minimal Metric test double. + + :param timestamp: Timestamp of the metric + :type timestamp: datetime + :param value: Metric value + :type value: float | None + :param metadata: Metadata dictionary + :type metadata: Dict[str, object] + """ + + timestamp: datetime + value: Optional[float] + metadata: Dict[str, object] + + +class FakeRepo: + """Configurable MetricsRepository stub. + + :param metrics: Metrics to return from query_metrics + :type metrics: List[FakeMetric] + """ + + def __init__(self, metrics: List[FakeMetric]) -> None: + """Initialise with a fixed list of metrics. + + :param metrics: Metrics returned by query_metrics + :type metrics: List[FakeMetric] + """ + self._metrics = metrics + + def query_metrics( + self, + metric_name: str, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + labels: Optional[Dict] = None, + ) -> List[FakeMetric]: + """Return preconfigured metrics regardless of filters. + + :param metric_name: Ignored + :param start: Ignored + :param end: Ignored + :param labels: Ignored + :return: Preconfigured metric list + :rtype: List[FakeMetric] + """ + return self._metrics + + +# ============================================================ +# Helpers +# ============================================================ + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_END = _NOW + timedelta(hours=2) + + +def _make_water_metric( + ts: datetime, + value: Optional[float], + zone: str = "ES", + scope: str = "operational", + zone_status: str = "complete", + valid: str = "true", + unit: str = "stress-l/kWh", +) -> FakeMetric: + """Return a FakeMetric with water-impact metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Zone code + :param scope: Scope + :param zone_status: Zone status string + :param valid: Validity string ('true'/'false') + :param unit: Unit string + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "impact_type": "water", + "zone": zone, + "scope": scope, + "zone_status": zone_status, + "valid": valid, + "unit": unit, + }, + ) + + +# ============================================================ +# get_impacts — routing by impact_type +# ============================================================ + + +def test_get_impacts_none_type_returns_water_results() -> None: + """impact_type=None should include water results. + + :return: None + :rtype: None + """ + metric = _make_water_metric(_NOW, 5.0) + svc = ImpactService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impacts(impact_type=None) + + assert len(results) == 1 + + +def test_get_impacts_water_type_returns_water_results() -> None: + """impact_type='water' should include water results. + + :return: None + :rtype: None + """ + metric = _make_water_metric(_NOW, 5.0) + svc = ImpactService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impacts(impact_type="water") + + assert len(results) == 1 + + +def test_get_impacts_unknown_type_returns_empty() -> None: + """Unsupported impact_type should produce an empty list. + + :return: None + :rtype: None + """ + metric = _make_water_metric(_NOW, 5.0) + svc = ImpactService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impacts(impact_type="carbon") + + assert results == [] + + +# ============================================================ +# _get_water_impacts — value filtering +# ============================================================ + + +def test_get_impacts_filters_none_values() -> None: + """Metrics with value=None must be excluded. + + :return: None + :rtype: None + """ + metrics = [ + _make_water_metric(_NOW, None), + _make_water_metric(_NOW + timedelta(hours=1), 3.0), + ] + svc = ImpactService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impacts() + + assert len(results) == 1 + assert results[0].series[0].values[0][1] == pytest.approx(3.0, rel=1e-9) + + +def test_get_impacts_filters_negative_values() -> None: + """Metrics with negative values must be excluded. + + :return: None + :rtype: None + """ + metrics = [ + _make_water_metric(_NOW, -1.0), + _make_water_metric(_NOW + timedelta(hours=1), 2.0), + ] + svc = ImpactService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impacts() + + assert len(results) == 1 + assert results[0].series[0].values[0][1] == pytest.approx(2.0, rel=1e-9) + + +def test_get_impacts_returns_empty_when_all_invalid() -> None: + """All-invalid metric list should produce no Impact objects. + + :return: None + :rtype: None + """ + metrics = [ + _make_water_metric(_NOW, None), + _make_water_metric(_NOW, -5.0), + ] + svc = ImpactService(metrics_repo=FakeRepo(metrics)) + + assert svc.get_impacts() == [] + + +# ============================================================ +# _get_water_impacts — series vs aggregate mode +# ============================================================ + + +def test_get_impacts_returns_series_by_default() -> None: + """Without aggregate=True the result should be an Impact with series. + + :return: None + :rtype: None + """ + from wattnet.api.models.impact import Impact + + metric = _make_water_metric(_NOW, 5.0) + svc = ImpactService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impacts(aggregate=False) + + assert len(results) == 1 + assert isinstance(results[0], Impact) + assert results[0].series[0].values[0][1] == pytest.approx(5.0, rel=1e-9) + + +def test_get_impacts_returns_aggregate_when_requested() -> None: + """aggregate=True with start/end should return ImpactAggregate objects. + + :return: None + :rtype: None + """ + metric = _make_water_metric(_NOW, 10.0) + svc = ImpactService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impacts(aggregate=True, start=_NOW, end=_END) + + assert len(results) == 1 + assert isinstance(results[0], ImpactAggregate) + + +# ============================================================ +# _aggregate_metrics — zone_status priority +# ============================================================ + + +def test_aggregate_zone_status_picks_lowest_priority() -> None: + """When metrics mix 'complete' and 'preview', the result must be 'preview'. + + :return: None + :rtype: None + """ + metrics = [ + _make_water_metric(_NOW, 10.0, zone_status="complete"), + _make_water_metric(_NOW + timedelta(hours=1), 10.0, zone_status="preview"), + ] + svc = ImpactService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impacts(aggregate=True, start=_NOW, end=_END) + + assert results[0].zone_status == "preview" + + +def test_aggregate_zone_status_missing_is_lowest() -> None: + """'missing' has the lowest priority and must win over 'preview'/'complete'. + + :return: None + :rtype: None + """ + metrics = [ + _make_water_metric(_NOW, 10.0, zone_status="complete"), + _make_water_metric(_NOW + timedelta(hours=1), 10.0, zone_status="missing"), + ] + svc = ImpactService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impacts(aggregate=True, start=_NOW, end=_END) + + assert results[0].zone_status == "missing" + + +def test_aggregate_valid_false_when_any_invalid() -> None: + """Aggregate is invalid if any metric has valid != 'true'. + + :return: None + :rtype: None + """ + metrics = [ + _make_water_metric(_NOW, 10.0, valid="true"), + _make_water_metric(_NOW + timedelta(hours=1), 10.0, valid="false"), + ] + svc = ImpactService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impacts(aggregate=True, start=_NOW, end=_END) + + assert results[0].valid is False + + +def test_aggregate_valid_true_when_all_valid() -> None: + """Aggregate is valid only when all metrics have valid='true'. + + :return: None + :rtype: None + """ + metrics = [ + _make_water_metric(_NOW, 10.0, valid="true"), + _make_water_metric(_NOW + timedelta(hours=1), 5.0, valid="true"), + ] + svc = ImpactService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impacts(aggregate=True, start=_NOW, end=_END) + + assert results[0].valid is True + + +def test_aggregate_coverage_global() -> None: + """use_global=True must produce coverage='global'. + + :return: None + :rtype: None + """ + metric = _make_water_metric(_NOW, 5.0) + svc = ImpactService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impacts(aggregate=True, start=_NOW, end=_END, use_global=True) + + assert results[0].coverage == "global" + + +def test_aggregate_coverage_local() -> None: + """use_global=False must produce coverage='local'. + + :return: None + :rtype: None + """ + metric = _make_water_metric(_NOW, 5.0) + svc = ImpactService(metrics_repo=FakeRepo([metric])) + + results = svc.get_impacts(aggregate=True, start=_NOW, end=_END, use_global=False) + + assert results[0].coverage == "local" + + +# ============================================================ +# _group_metrics_series — series grouping +# ============================================================ + + +def test_series_groups_by_validity_subgroups() -> None: + """Metrics with different valid/zone_status pairs must produce separate series. + + :return: None + :rtype: None + """ + metrics = [ + _make_water_metric(_NOW, 1.0, valid="true", zone_status="complete"), + _make_water_metric( + _NOW + timedelta(hours=1), 2.0, valid="false", zone_status="preview" + ), + ] + svc = ImpactService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impacts(aggregate=False) + + assert len(results) == 1 + assert len(results[0].series) == 2 + + +def test_series_groups_separate_zones() -> None: + """Metrics for different zones must produce separate Impact objects. + + :return: None + :rtype: None + """ + metrics = [ + _make_water_metric(_NOW, 1.0, zone="ES"), + _make_water_metric(_NOW, 2.0, zone="FR"), + ] + svc = ImpactService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_impacts(aggregate=False) + + assert len(results) == 2 + zones = {r.zone for r in results} + assert zones == {"ES", "FR"} + + +# ============================================================ +# get_impacts — filter forwarding +# ============================================================ + + +def test_get_impacts_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = ImpactService(metrics_repo=FakeRepo([])) + assert svc.get_impacts(zone="ES") == [] + + +def test_get_impacts_with_scope_filter() -> None: + """scope filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = ImpactService(metrics_repo=FakeRepo([])) + assert svc.get_impacts(scope="operational") == [] diff --git a/tests/unit/service/test_imports.py b/tests/unit/service/test_imports.py new file mode 100644 index 0000000..65cb421 --- /dev/null +++ b/tests/unit/service/test_imports.py @@ -0,0 +1,226 @@ +""" +Unit tests for wattnet.api.service.imports module. + +These tests validate: +- Filtering of invalid (None/negative) metric values +- Hierarchical grouping: Import → ImportSeries → ImportBlock +- Separation into series by (valid, zone_status) pairs +- Separation into blocks by (from/source, data_state, datasource) +- Chronological ordering of values within blocks +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.service.imports import ImportService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _m( + ts: datetime, + value: float | None, + zone: str = "ES", + unit: str = "MW", + from_zone: str = "FR", + data_state: str = "official", + datasource: str = "ENTSO-E", + valid: bool = True, + zone_status: str = "complete", +) -> FakeMetric: + """Build a FakeMetric with import metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Destination zone code + :param unit: Energy unit + :param from_zone: Origin zone label (DB uses 'from') + :param data_state: Data state + :param datasource: Data provider + :param valid: Validity flag + :param zone_status: Zone status + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "zone": zone, + "unit": unit, + "from": from_zone, + "data_state": data_state, + "datasource": datasource, + "valid": valid, + "zone_status": zone_status, + }, + ) + + +# ============================================================ +# get_imports — basic filtering +# ============================================================ + + +def test_get_imports_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = ImportService(metrics_repo=FakeRepo([])) + assert svc.get_imports() == [] + + +def test_get_imports_filters_none_values() -> None: + """Metrics with value=None must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, None), _m(_NOW + timedelta(hours=1), 500.0)] + svc = ImportService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_imports() + + assert len(results) == 1 + assert results[0].series[0].imports[0].values[0][1] == pytest.approx(500.0) + + +def test_get_imports_filters_negative_values() -> None: + """Metrics with negative values must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, -5.0), _m(_NOW + timedelta(hours=1), 250.0)] + svc = ImportService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_imports() + + assert len(results) == 1 + assert results[0].series[0].imports[0].values[0][1] == pytest.approx(250.0) + + +# ============================================================ +# _group_metrics — structure +# ============================================================ + + +def test_get_imports_one_object_per_zone() -> None: + """Metrics for separate destination zones must produce separate Import objects. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, 100.0, zone="ES"), _m(_NOW, 200.0, zone="FR")] + svc = ImportService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_imports() + + assert len(results) == 2 + assert {r.zone for r in results} == {"ES", "FR"} + + +def test_get_imports_separate_blocks_by_source() -> None: + """Metrics from different origin zones must produce separate ImportBlocks. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, from_zone="FR"), + _m(_NOW + timedelta(hours=1), 50.0, from_zone="PT"), + ] + svc = ImportService(metrics_repo=FakeRepo(metrics)) + + blocks = svc.get_imports()[0].series[0].imports + + assert {b.source for b in blocks} == {"FR", "PT"} + + +def test_get_imports_source_field_is_populated_from_from_label() -> None: + """The ImportBlock 'source' field must be populated from the 'from' label. + + :return: None + :rtype: None + """ + svc = ImportService(metrics_repo=FakeRepo([_m(_NOW, 100.0, from_zone="DE")])) + + block = svc.get_imports()[0].series[0].imports[0] + + assert block.source == "DE" + + +def test_get_imports_separate_series_by_validity() -> None: + """Different (valid, zone_status) pairs must create separate ImportSeries. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, valid=True, zone_status="complete"), + _m(_NOW + timedelta(hours=1), 80.0, valid=False, zone_status="preview"), + ] + svc = ImportService(metrics_repo=FakeRepo(metrics)) + + assert len(svc.get_imports()[0].series) == 2 + + +def test_get_imports_with_source_filter() -> None: + """Calling get_imports(source=...) must not raise and returns a list. + + :return: None + :rtype: None + """ + svc = ImportService(metrics_repo=FakeRepo([])) + assert svc.get_imports(source="FR") == [] + + +def test_get_imports_block_values_sorted_by_timestamp() -> None: + """Values within a block must be sorted ascending by timestamp. + + :return: None + :rtype: None + """ + t0 = _NOW + t2 = _NOW + timedelta(hours=2) + t1 = _NOW + timedelta(hours=1) + + metrics = [_m(t0, 1.0), _m(t2, 3.0), _m(t1, 2.0)] + svc = ImportService(metrics_repo=FakeRepo(metrics)) + + values = svc.get_imports()[0].series[0].imports[0].values + + assert values[0][0] == t0 + assert values[1][0] == t1 + assert values[2][0] == t2 + + +# ============================================================ +# get_imports — filter forwarding +# ============================================================ + + +def test_get_imports_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = ImportService(metrics_repo=FakeRepo([])) + assert svc.get_imports(zone="ES") == [] + + +def test_get_imports_with_source_filter() -> None: + """source filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = ImportService(metrics_repo=FakeRepo([])) + assert svc.get_imports(source="FR") == [] diff --git a/tests/unit/service/test_load.py b/tests/unit/service/test_load.py new file mode 100644 index 0000000..70d4f9b --- /dev/null +++ b/tests/unit/service/test_load.py @@ -0,0 +1,192 @@ +""" +Unit tests for wattnet.api.service.load module. + +These tests validate: +- Filtering of invalid (None/negative) metric values +- Hierarchical grouping: Load → LoadSeries → LoadBlock +- Separation into series by (valid, zone_status) pairs +- Separation into blocks by (data_state, datasource) +- Chronological ordering of values within blocks +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.service.load import LoadService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _m( + ts: datetime, + value: float | None, + zone: str = "ES", + unit: str = "MW", + data_state: str = "official", + datasource: str = "ENTSO-E", + valid: bool = True, + zone_status: str = "complete", +) -> FakeMetric: + """Build a FakeMetric with load metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Zone code + :param unit: Energy unit + :param data_state: Data state + :param datasource: Data provider + :param valid: Validity flag + :param zone_status: Zone status + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "zone": zone, + "unit": unit, + "data_state": data_state, + "datasource": datasource, + "valid": valid, + "zone_status": zone_status, + }, + ) + + +# ============================================================ +# get_load — basic filtering +# ============================================================ + + +def test_get_load_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = LoadService(metrics_repo=FakeRepo([])) + assert svc.get_load() == [] + + +def test_get_load_filters_none_values() -> None: + """Metrics with value=None must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, None), _m(_NOW + timedelta(hours=1), 5000.0)] + svc = LoadService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_load() + + assert len(results) == 1 + assert results[0].series[0].blocks[0].values[0][1] == pytest.approx(5000.0) + + +def test_get_load_filters_negative_values() -> None: + """Metrics with negative values must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, -1.0), _m(_NOW + timedelta(hours=1), 3000.0)] + svc = LoadService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_load() + + assert len(results) == 1 + assert results[0].series[0].blocks[0].values[0][1] == pytest.approx(3000.0) + + +# ============================================================ +# _group_metrics — structure +# ============================================================ + + +def test_get_load_one_object_per_zone() -> None: + """Metrics for separate zones must produce separate Load objects. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, 1000.0, zone="ES"), _m(_NOW, 2000.0, zone="FR")] + svc = LoadService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_load() + + assert len(results) == 2 + assert {r.zone for r in results} == {"ES", "FR"} + + +def test_get_load_separate_series_by_validity() -> None: + """Different (valid, zone_status) pairs must create separate LoadSeries. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 1000.0, valid=True, zone_status="complete"), + _m(_NOW + timedelta(hours=1), 900.0, valid=False, zone_status="preview"), + ] + svc = LoadService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_load() + + assert len(results[0].series) == 2 + + +def test_get_load_separate_blocks_by_data_state() -> None: + """Different data_state values in the same series must produce separate blocks. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 1000.0, data_state="official"), + _m(_NOW + timedelta(hours=1), 900.0, data_state="estimated"), + ] + svc = LoadService(metrics_repo=FakeRepo(metrics)) + + blocks = svc.get_load()[0].series[0].blocks + + assert {b.data_state for b in blocks} == {"official", "estimated"} + + +def test_get_load_block_values_sorted_by_timestamp() -> None: + """Values within a block must be sorted ascending by timestamp. + + :return: None + :rtype: None + """ + t0 = _NOW + t2 = _NOW + timedelta(hours=2) + t1 = _NOW + timedelta(hours=1) + + metrics = [_m(t0, 1.0), _m(t2, 3.0), _m(t1, 2.0)] + svc = LoadService(metrics_repo=FakeRepo(metrics)) + + values = svc.get_load()[0].series[0].blocks[0].values + + assert values[0][0] == t0 + assert values[1][0] == t1 + assert values[2][0] == t2 + + +# ============================================================ +# get_load — filter forwarding +# ============================================================ + + +def test_get_load_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = LoadService(metrics_repo=FakeRepo([])) + assert svc.get_load(zone="ES") == [] diff --git a/tests/unit/service/test_mix.py b/tests/unit/service/test_mix.py new file mode 100644 index 0000000..9bd7fed --- /dev/null +++ b/tests/unit/service/test_mix.py @@ -0,0 +1,205 @@ +""" +Unit tests for wattnet.api.service.mix module. + +These tests validate: +- Filtering of invalid (None/negative) metric values +- Hierarchical grouping: Mix → MixSeries → MixBlock +- Pruning of empty series and empty top-level Mix objects +- Separation into blocks by (production_type, data_state, datasource) +- Chronological ordering of values within blocks +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.service.mix import MixService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _m( + ts: datetime, + value: float | None, + zone: str = "ES", + unit: str = "MW", + production_type: str = "solar", + data_state: str = "official", + datasource: str = "flow_tracing", + valid: bool = True, + zone_status: str = "complete", +) -> FakeMetric: + """Build a FakeMetric with mix metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Zone code + :param unit: Energy unit + :param production_type: Energy source type + :param data_state: Data state + :param datasource: Data source + :param valid: Validity flag + :param zone_status: Zone status + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "zone": zone, + "unit": unit, + "production_type": production_type, + "data_state": data_state, + "datasource": datasource, + "valid": valid, + "zone_status": zone_status, + }, + ) + + +# ============================================================ +# get_mix — basic filtering +# ============================================================ + + +def test_get_mix_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = MixService(metrics_repo=FakeRepo([])) + assert svc.get_mix() == [] + + +def test_get_mix_filters_none_values() -> None: + """Metrics with value=None must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, None), _m(_NOW + timedelta(hours=1), 300.0)] + svc = MixService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_mix() + + assert len(results) == 1 + assert results[0].series[0].production[0].values[0][1] == pytest.approx(300.0) + + +def test_get_mix_filters_negative_values() -> None: + """Metrics with negative values must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, -10.0), _m(_NOW + timedelta(hours=1), 150.0)] + svc = MixService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_mix() + + assert len(results) == 1 + assert results[0].series[0].production[0].values[0][1] == pytest.approx(150.0) + + +# ============================================================ +# _group_metrics — structure +# ============================================================ + + +def test_get_mix_one_object_per_zone() -> None: + """Metrics for separate zones must produce separate Mix objects. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, 100.0, zone="ES"), _m(_NOW, 200.0, zone="FR")] + svc = MixService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_mix() + + assert len(results) == 2 + assert {r.zone for r in results} == {"ES", "FR"} + + +def test_get_mix_separate_blocks_by_production_type() -> None: + """Different production types must produce separate MixBlock objects. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, production_type="solar"), + _m(_NOW, 80.0, production_type="wind_onshore"), + ] + svc = MixService(metrics_repo=FakeRepo(metrics)) + + blocks = svc.get_mix()[0].series[0].production + + assert {b.production_type for b in blocks} == {"solar", "wind_onshore"} + + +def test_get_mix_separate_series_by_validity() -> None: + """Different (valid, zone_status) pairs must create separate MixSeries. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 100.0, valid=True, zone_status="complete"), + _m(_NOW + timedelta(hours=1), 80.0, valid=False, zone_status="preview"), + ] + svc = MixService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_mix() + + assert len(results[0].series) == 2 + + +def test_get_mix_block_values_sorted_by_timestamp() -> None: + """Values within a block must be sorted ascending by timestamp. + + :return: None + :rtype: None + """ + t0 = _NOW + t2 = _NOW + timedelta(hours=2) + t1 = _NOW + timedelta(hours=1) + + metrics = [_m(t0, 1.0), _m(t2, 3.0), _m(t1, 2.0)] + svc = MixService(metrics_repo=FakeRepo(metrics)) + + values = svc.get_mix()[0].series[0].production[0].values + + assert values[0][0] == t0 + assert values[1][0] == t1 + assert values[2][0] == t2 + + +# ============================================================ +# get_mix — filter forwarding +# ============================================================ + + +def test_get_mix_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = MixService(metrics_repo=FakeRepo([])) + assert svc.get_mix(zone="ES") == [] + + +def test_get_mix_with_production_type_filter() -> None: + """production_type filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = MixService(metrics_repo=FakeRepo([])) + assert svc.get_mix(production_type="solar") == [] diff --git a/tests/unit/service/test_mix_share.py b/tests/unit/service/test_mix_share.py new file mode 100644 index 0000000..3b39573 --- /dev/null +++ b/tests/unit/service/test_mix_share.py @@ -0,0 +1,191 @@ +""" +Unit tests for wattnet.api.service.mix_share module. + +These tests validate: +- Empty repository returns empty list +- Hierarchical grouping: MixShare → MixShareSeries → MixShareBlock +- Separation into series by (valid, zone_status) pairs +- Separation into blocks by origin zone (source label) +- Chronological ordering of values within blocks +- Correct unit is always '%' +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.service.mix_share import MixShareService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _m( + ts: datetime, + value: float, + zone: str = "ES", + source: str = "FR", + valid: bool = True, + zone_status: str = "complete", +) -> FakeMetric: + """Build a FakeMetric with mix share metadata. + + :param ts: Timestamp + :param value: Metric value + :param zone: Destination zone code + :param source: Origin zone code + :param valid: Validity flag + :param zone_status: Zone status + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "zone": zone, + "source": source, + "valid": valid, + "zone_status": zone_status, + }, + ) + + +# ============================================================ +# get_mix_share — basic +# ============================================================ + + +def test_get_mix_share_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = MixShareService(metrics_repo=FakeRepo([])) + assert svc.get_mix_share() == [] + + +def test_get_mix_share_unit_is_percent() -> None: + """The unit field on every MixShare must always be '%'. + + :return: None + :rtype: None + """ + svc = MixShareService(metrics_repo=FakeRepo([_m(_NOW, 0.3)])) + assert svc.get_mix_share()[0].unit == "%" + + +# ============================================================ +# _group_metrics — structure +# ============================================================ + + +def test_get_mix_share_one_object_per_zone() -> None: + """Metrics for separate zones must produce separate MixShare objects. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, 0.3, zone="ES"), _m(_NOW, 0.4, zone="FR")] + svc = MixShareService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_mix_share() + + assert len(results) == 2 + assert {r.zone for r in results} == {"ES", "FR"} + + +def test_get_mix_share_separate_blocks_by_origin() -> None: + """Metrics with different source zones must produce separate MixShareBlocks. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 0.3, source="FR"), + _m(_NOW + timedelta(hours=1), 0.2, source="PT"), + ] + svc = MixShareService(metrics_repo=FakeRepo(metrics)) + + blocks = svc.get_mix_share()[0].series[0].shares + + assert {b.origin for b in blocks} == {"FR", "PT"} + + +def test_get_mix_share_separate_series_by_validity() -> None: + """Different (valid, zone_status) pairs must create separate MixShareSeries. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 0.3, valid=True, zone_status="complete"), + _m(_NOW + timedelta(hours=1), 0.2, valid=False, zone_status="preview"), + ] + svc = MixShareService(metrics_repo=FakeRepo(metrics)) + + assert len(svc.get_mix_share()[0].series) == 2 + + +def test_get_mix_share_with_origin_filter() -> None: + """Calling get_mix_share(origin=...) must not raise and returns a list. + + :return: None + :rtype: None + """ + svc = MixShareService(metrics_repo=FakeRepo([])) + assert svc.get_mix_share(origin="FR") == [] + + +def test_get_mix_share_block_values_sorted_by_timestamp() -> None: + """Values within a block must be sorted ascending by timestamp. + + :return: None + :rtype: None + """ + t0 = _NOW + t2 = _NOW + timedelta(hours=2) + t1 = _NOW + timedelta(hours=1) + + metrics = [_m(t0, 0.1), _m(t2, 0.3), _m(t1, 0.2)] + svc = MixShareService(metrics_repo=FakeRepo(metrics)) + + values = svc.get_mix_share()[0].series[0].shares[0].values + + assert values[0][0] == t0 + assert values[1][0] == t1 + assert values[2][0] == t2 + + +# ============================================================ +# get_mix_share — filter forwarding +# ============================================================ + + +def test_get_mix_share_none_source_falls_back_to_unknown() -> None: + """A metric missing the 'source' key must produce origin='unknown', not crash. + + :return: None + :rtype: None + """ + metric = FakeMetric( + timestamp=_NOW, + value=0.5, + metadata={"zone": "ES", "valid": True, "zone_status": "complete"}, + ) + svc = MixShareService(metrics_repo=FakeRepo([metric])) + + block = svc.get_mix_share()[0].series[0].shares[0] + + assert block.origin == "unknown" + + +def test_get_mix_share_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = MixShareService(metrics_repo=FakeRepo([])) + assert svc.get_mix_share(zone="ES") == [] diff --git a/tests/unit/service/test_operations.py b/tests/unit/service/test_operations.py index 9221899..4415169 100644 --- a/tests/unit/service/test_operations.py +++ b/tests/unit/service/test_operations.py @@ -11,7 +11,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional, Tuple import pytest @@ -52,7 +52,7 @@ def test_group_metrics_by_single_field() -> None: :return: None :rtype: None """ - now: datetime = datetime.now(UTC) + now: datetime = datetime.now(timezone.utc) metrics: List[FakeMetric] = [ FakeMetric(now, 1.0, {"zone_id": "A"}), @@ -73,7 +73,7 @@ def test_group_metrics_by_multiple_fields() -> None: :return: None :rtype: None """ - now: datetime = datetime.now(UTC) + now: datetime = datetime.now(timezone.utc) metrics: List[FakeMetric] = [ FakeMetric(now, 1.0, {"zone_id": "A", "type": "solar"}), @@ -102,8 +102,8 @@ def test_compute_time_weighted_average_empty() -> None: """ result: float = ops.compute_time_weighted_average( [], - datetime.now(UTC), - datetime.now(UTC), + datetime.now(timezone.utc), + datetime.now(timezone.utc), ) assert result == pytest.approx(0.0, rel=1e-9) @@ -154,6 +154,24 @@ def test_compute_time_weighted_average_piecewise() -> None: assert result == pytest.approx(15.0, rel=1e-9) +def test_compute_time_weighted_average_metric_at_end_returns_its_value() -> None: + """Fallback when total_duration is 0 (metric timestamp == end). + + :return: None + :rtype: None + """ + start: datetime = datetime(2025, 1, 1, 0, 0, 0) + end: datetime = start + timedelta(hours=1) + + metrics: List[FakeMetric] = [ + FakeMetric(end, 7.50, {}), + ] + + result: float = ops.compute_time_weighted_average(metrics, start, end) + + assert result == pytest.approx(7.50, rel=1e-9) + + def test_compute_time_weighted_average_partial_overlap() -> None: """ Ensure integration respects start boundary clipping. @@ -203,6 +221,34 @@ def test_build_time_series_sorted() -> None: assert series[1][0] == t0 +def test_compute_time_weighted_average_skips_none_value() -> None: + """Metric with value=None is skipped; the next metric still contributes. + + :return: None + :rtype: None + """ + start: datetime = datetime(2025, 1, 1, 0, 0, 0) + mid: datetime = start + timedelta(hours=1) + end: datetime = start + timedelta(hours=2) + + metrics: List[FakeMetric] = [ + FakeMetric(start, None, {}), + FakeMetric(mid, 20.0, {}), + ] + + result: float = ops.compute_time_weighted_average(metrics, start, end) + assert result == pytest.approx(20.0, rel=1e-9) + + +def test_resolve_zone_status_empty_returns_missing() -> None: + """resolve_zone_status([]) must return 'missing' without raising. + + :return: None + :rtype: None + """ + assert ops.resolve_zone_status([]) == "missing" + + def test_build_time_series_ignores_none_values() -> None: """ Ensure metrics with None values are excluded. @@ -210,7 +256,7 @@ def test_build_time_series_ignores_none_values() -> None: :return: None :rtype: None """ - t: datetime = datetime.now(UTC) + t: datetime = datetime.now(timezone.utc) metrics: List[FakeMetric] = [ FakeMetric(t, None, {}), diff --git a/tests/unit/service/test_scores.py b/tests/unit/service/test_scores.py new file mode 100644 index 0000000..7ea409c --- /dev/null +++ b/tests/unit/service/test_scores.py @@ -0,0 +1,269 @@ +""" +Unit tests for wattnet.api.service.scores module. + +These tests validate: +- Filtering of None metric values (scores allow any value including negative) +- Series vs aggregate mode routing +- Aggregation: zone_status priority resolution, valid flag, coverage +- Time-series grouping by (scope, zone) and validity subgroups +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Optional + +from tests.unit.service.helpers import FakeMetric, FakeRepo +from wattnet.api.models.score import GreenScore, GreenScoreAggregate +from wattnet.api.service.scores import ScoreService + +_NOW = datetime(2025, 6, 1, 0, 0, 0, tzinfo=timezone.utc) +_END = _NOW + timedelta(hours=2) + + +def _m( + ts: datetime, + value: Optional[float], + zone: str = "ES", + scope: str = "operational", + valid: str = "true", + zone_status: str = "complete", +) -> FakeMetric: + """Build a FakeMetric with score metadata. + + :param ts: Timestamp + :param value: Metric value (None → excluded) + :param zone: Zone code + :param scope: Score scope + :param valid: Validity string ('true'/'false') + :param zone_status: Zone status + :return: FakeMetric + :rtype: FakeMetric + """ + return FakeMetric( + timestamp=ts, + value=value, + metadata={ + "zone": zone, + "scope": scope, + "valid": valid, + "zone_status": zone_status, + }, + ) + + +# ============================================================ +# get_scores — filtering +# ============================================================ + + +def test_get_scores_returns_empty_when_no_metrics() -> None: + """Empty repository must produce an empty result. + + :return: None + :rtype: None + """ + svc = ScoreService(metrics_repo=FakeRepo([])) + assert svc.get_scores() == [] + + +def test_get_scores_filters_none_values() -> None: + """Metrics with value=None must be excluded. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, None), _m(_NOW + timedelta(hours=1), 75.0)] + svc = ScoreService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_scores() + + assert len(results) == 1 + + +def test_get_scores_filters_negative_values() -> None: + """ScoreService must filter out negative sentinel values (valid range is [0, 100]). + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, -10.0), _m(_NOW + timedelta(hours=1), 50.0)] + svc = ScoreService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_scores() + + assert len(results) == 1 + values = results[0].series[0].values + assert len(values) == 1 + assert values[0][1] == 50.0 + + +# ============================================================ +# get_scores — series vs aggregate routing +# ============================================================ + + +def test_get_scores_returns_green_score_by_default() -> None: + """Default (aggregate=False) must return GreenScore time-series objects. + + :return: None + :rtype: None + """ + svc = ScoreService(metrics_repo=FakeRepo([_m(_NOW, 70.0)])) + + results = svc.get_scores(aggregate=False) + + assert isinstance(results[0], GreenScore) + + +def test_get_scores_returns_aggregate_when_requested() -> None: + """aggregate=True with start/end must return GreenScoreAggregate objects. + + :return: None + :rtype: None + """ + svc = ScoreService(metrics_repo=FakeRepo([_m(_NOW, 70.0)])) + + results = svc.get_scores(aggregate=True, start=_NOW, end=_END) + + assert isinstance(results[0], GreenScoreAggregate) + + +def test_get_scores_no_start_end_falls_back_to_series() -> None: + """aggregate=True without start/end must fall back to series mode. + + :return: None + :rtype: None + """ + svc = ScoreService(metrics_repo=FakeRepo([_m(_NOW, 70.0)])) + + results = svc.get_scores(aggregate=True) + + assert isinstance(results[0], GreenScore) + + +# ============================================================ +# _aggregate_metrics — zone_status / valid / coverage +# ============================================================ + + +def test_score_aggregate_zone_status_picks_lowest_priority() -> None: + """Mixed 'complete'/'missing' must resolve to 'missing'. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 70.0, zone_status="complete"), + _m(_NOW + timedelta(hours=1), 60.0, zone_status="missing"), + ] + svc = ScoreService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_scores(aggregate=True, start=_NOW, end=_END) + + assert results[0].zone_status == "missing" + + +def test_score_aggregate_valid_false_when_any_invalid() -> None: + """Aggregate is invalid when at least one metric has valid='false'. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 70.0, valid="true"), + _m(_NOW + timedelta(hours=1), 60.0, valid="false"), + ] + svc = ScoreService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_scores(aggregate=True, start=_NOW, end=_END) + + assert results[0].valid is False + + +def test_score_aggregate_coverage_global() -> None: + """use_global=True must produce coverage='global'. + + :return: None + :rtype: None + """ + svc = ScoreService(metrics_repo=FakeRepo([_m(_NOW, 70.0)])) + + results = svc.get_scores(aggregate=True, start=_NOW, end=_END, use_global=True) + + assert results[0].coverage == "global" + + +def test_score_aggregate_coverage_local() -> None: + """use_global=False must produce coverage='local'. + + :return: None + :rtype: None + """ + svc = ScoreService(metrics_repo=FakeRepo([_m(_NOW, 70.0)])) + + results = svc.get_scores(aggregate=True, start=_NOW, end=_END, use_global=False) + + assert results[0].coverage == "local" + + +# ============================================================ +# _group_metrics_series — series grouping +# ============================================================ + + +def test_score_series_separate_zones() -> None: + """Metrics for different zones must produce separate GreenScore objects. + + :return: None + :rtype: None + """ + metrics = [_m(_NOW, 70.0, zone="ES"), _m(_NOW, 80.0, zone="FR")] + svc = ScoreService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_scores(aggregate=False) + + assert len(results) == 2 + assert {r.zone for r in results} == {"ES", "FR"} + + +def test_score_series_groups_by_validity_subgroups() -> None: + """Metrics with different valid/zone_status must produce separate series. + + :return: None + :rtype: None + """ + metrics = [ + _m(_NOW, 70.0, valid="true", zone_status="complete"), + _m(_NOW + timedelta(hours=1), 50.0, valid="false", zone_status="preview"), + ] + svc = ScoreService(metrics_repo=FakeRepo(metrics)) + + results = svc.get_scores(aggregate=False) + + assert len(results[0].series) == 2 + + +# ============================================================ +# get_scores — filter forwarding +# ============================================================ + + +def test_get_scores_with_zone_filter() -> None: + """zone filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = ScoreService(metrics_repo=FakeRepo([])) + assert svc.get_scores(zone="ES") == [] + + +def test_get_scores_with_scope_filter() -> None: + """scope filter branch is executed when provided. + + :return: None + :rtype: None + """ + svc = ScoreService(metrics_repo=FakeRepo([])) + assert svc.get_scores(scope="operational") == [] diff --git a/tests/unit/service/test_zones.py b/tests/unit/service/test_zones.py new file mode 100644 index 0000000..e0bce63 --- /dev/null +++ b/tests/unit/service/test_zones.py @@ -0,0 +1,246 @@ +""" +Unit tests for wattnet.api.service.zones module. + +These tests validate: +- Correct merging of zone metadata with crossborder neighbours +- Alphabetical sorting of the returned list +- Fallback to empty neighbours when zone absent from crossborders file +- Provider normalisation mapping and rejection of unknown providers +- YAML loader validation (must be a list) +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from wattnet.api.service.zones import ZoneService + +# ============================================================ +# Helpers +# ============================================================ + + +def _write_yaml(path: Path, data: object) -> None: + """Serialise *data* as YAML and write it to *path*. + + :param path: Destination file path + :type path: Path + :param data: Python object to serialise + :type data: object + :return: None + :rtype: None + """ + path.write_text(yaml.dump(data), encoding="utf-8") + + +def _make_zone_entry( + zone_id: str = "ES", + full_name: str = "Spain", + eic_code: str = "10YES-REE------0", + country_code: str = "ESP", + country_name: str = "Spain", + provider: str = "entsoe", +) -> dict: + """Return a minimal zone YAML entry dict. + + :param zone_id: Zone code + :param full_name: Full descriptive name + :param eic_code: EIC code string + :param country_code: ISO 3166-1 alpha-3 country code + :param country_name: Country name + :param provider: Raw provider key + :return: Zone entry dict + :rtype: dict + """ + return { + "zone_id": zone_id, + "full_name": full_name, + "eic_code": eic_code, + "country_code": country_code, + "country_name": country_name, + "provider": provider, + } + + +def _make_service( + tmp_path: Path, + zones: list, + crossborders: list, +) -> ZoneService: + """Write YAML fixtures and return a configured ZoneService. + + :param tmp_path: Temporary directory + :param zones: Zone list data + :param crossborders: Crossborder list data + :return: Configured ZoneService + :rtype: ZoneService + """ + zones_file = tmp_path / "zones.yaml" + cross_file = tmp_path / "crossborders.yaml" + _write_yaml(zones_file, zones) + _write_yaml(cross_file, crossborders) + return ZoneService(zones_file_path=zones_file, crossborders_file_path=cross_file) + + +# ============================================================ +# get_zones — sorting +# ============================================================ + + +def test_get_zones_sorted_alphabetically(tmp_path: Path) -> None: + """Returned zones must be sorted by zone code in ascending order. + + :param tmp_path: Temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + zones = [ + _make_zone_entry("PT"), + _make_zone_entry("ES"), + _make_zone_entry("FR"), + ] + svc = _make_service(tmp_path, zones, crossborders=[]) + + result = svc.get_zones() + + codes = [z.zone for z in result] + assert codes == sorted(codes) + + +# ============================================================ +# get_zones — neighbours merging +# ============================================================ + + +def test_get_zones_merges_neighbours(tmp_path: Path) -> None: + """Zones must carry the neighbours declared in the crossborders file. + + :param tmp_path: Temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + zones = [_make_zone_entry("ES")] + crossborders = [{"zone_id": "ES", "neighbours": ["FR", "PT"]}] + svc = _make_service(tmp_path, zones, crossborders) + + result = svc.get_zones() + + assert sorted(result[0].neighbours) == ["FR", "PT"] + + +def test_get_zones_empty_neighbours_when_absent_from_crossborders( + tmp_path: Path, +) -> None: + """Zones not listed in crossborders must get an empty neighbours list. + + :param tmp_path: Temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + zones = [_make_zone_entry("ES")] + svc = _make_service(tmp_path, zones, crossborders=[]) + + result = svc.get_zones() + + assert result[0].neighbours == [] + + +def test_get_zones_returns_correct_provider(tmp_path: Path) -> None: + """Provider must be normalised to the canonical API representation. + + :param tmp_path: Temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + zones = [_make_zone_entry("ES", provider="entsoe")] + svc = _make_service(tmp_path, zones, crossborders=[]) + + result = svc.get_zones() + + assert result[0].provider == "ENTSO-E" + + +# ============================================================ +# _normalize_provider +# ============================================================ + + +def test_normalize_provider_entsoe() -> None: + """'entsoe' must map to 'ENTSO-E'. + + :return: None + :rtype: None + """ + assert ZoneService._normalize_provider("entsoe") == "ENTSO-E" + + +def test_normalize_provider_elexon() -> None: + """'elexon' must map to 'Elexon'. + + :return: None + :rtype: None + """ + assert ZoneService._normalize_provider("elexon") == "Elexon" + + +def test_normalize_provider_epias() -> None: + """'epias' must map to 'EPIAS'. + + :return: None + :rtype: None + """ + assert ZoneService._normalize_provider("epias") == "EPIAS" + + +def test_normalize_provider_unknown_raises() -> None: + """Unknown provider key must raise ValueError. + + :return: None + :rtype: None + """ + with pytest.raises(ValueError, match="Unsupported provider"): + ZoneService._normalize_provider("opendata") + + +# ============================================================ +# _read_yaml_list +# ============================================================ + + +def test_read_yaml_list_raises_when_not_list(tmp_path: Path) -> None: + """A YAML file containing a mapping (not a list) must raise ValueError. + + :param tmp_path: Temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + bad_file = tmp_path / "bad.yaml" + _write_yaml(bad_file, {"key": "value"}) + + with pytest.raises(ValueError, match="must contain a list"): + ZoneService._read_yaml_list(bad_file) + + +def test_read_yaml_list_returns_list(tmp_path: Path) -> None: + """A valid YAML list file must be returned as a Python list. + + :param tmp_path: Temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + data = [{"zone_id": "ES"}, {"zone_id": "FR"}] + valid_file = tmp_path / "valid.yaml" + _write_yaml(valid_file, data) + + result = ZoneService._read_yaml_list(valid_file) + + assert result == data diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py new file mode 100644 index 0000000..cb223a7 --- /dev/null +++ b/tests/unit/test_settings.py @@ -0,0 +1,224 @@ +"""Unit tests for wattnet.api.settings.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Marker: skip existence tests when data is not available at the configured path +# --------------------------------------------------------------------------- + + +def _data_present() -> bool: + try: + from wattnet.api.settings import settings + + return settings.geojson_path.is_dir() and any(settings.geojson_path.iterdir()) + except Exception: + return False + + +DATA_PRESENT = pytest.mark.skipif( + not _data_present(), + reason="data not available at settings.geojson_path (check env or git submodule)", +) + + +# --------------------------------------------------------------------------- +# BASE_DIR structural tests (no data required) +# --------------------------------------------------------------------------- + + +def test_base_dir_is_three_parents_above_settings() -> None: + """Test that BASE_DIR is correctly set to the parent of the parent of the parent of settings.py.""" + from wattnet.api import settings as m + + settings_file = ( + Path(__file__).resolve().parents[2] / "wattnet" / "api" / "settings.py" + ) + assert m.BASE_DIR == settings_file.parent.parent.parent + + +def test_base_dir_exists() -> None: + """Test that BASE_DIR exists and is a directory.""" + from wattnet.api.settings import BASE_DIR + + assert BASE_DIR.is_dir(), f"BASE_DIR does not exist: {BASE_DIR}" + + +# --------------------------------------------------------------------------- +# Default field value tests — use model_fields, not the runtime singleton +# (the singleton may be overridden by .env files in development) +# --------------------------------------------------------------------------- + + +def test_default_paths_are_under_base_dir() -> None: + """Test that default paths are under BASE_DIR, ensuring they are relative to the project root.""" + from wattnet.api.settings import BASE_DIR, Settings + + fields = Settings.model_fields + base = str(BASE_DIR) + assert str(fields["geojson_path"].default).startswith(base) + assert str(fields["zones_file_path"].default).startswith(base) + assert str(fields["crossborders_file_path"].default).startswith(base) + + +def test_default_geojson_path_shape() -> None: + """Test that the default geojson_path is set to BASE_DIR / 'data' / 'geojson'.""" + from wattnet.api.settings import BASE_DIR, Settings + + default: Path = Settings.model_fields["geojson_path"].default + assert default == BASE_DIR / "data" / "geojson" + + +def test_default_zones_file_path_shape() -> None: + """Test that the default zones_file_path is set to correct path under BASE_DIR.""" + from wattnet.api.settings import BASE_DIR, Settings + + default: Path = Settings.model_fields["zones_file_path"].default + assert default == BASE_DIR / "data" / "zones" / "entsoe_full_zones.yaml" + + +def test_default_crossborders_file_path_shape() -> None: + """Test that the default crossborders_file_path is set to correct path under BASE_DIR.""" + from wattnet.api.settings import BASE_DIR, Settings + + default: Path = Settings.model_fields["crossborders_file_path"].default + assert default == ( + BASE_DIR / "data" / "zones" / "entsoe_full_crossborders.yaml" + ) + + +def test_default_server_fields() -> None: + """Test that default server fields are set to expected values.""" + from wattnet.api.settings import Settings + + fields = Settings.model_fields + assert fields["host"].default == "0.0.0.0" + assert fields["port"].default == 8000 + assert fields["debug"].default is False + + +def test_default_storage_fields() -> None: + """Test that default storage fields are set to expected values.""" + from wattnet.api.settings import Settings + + fields = Settings.model_fields + assert fields["timeseries_step_minutes"].default == 15 + assert fields["storage_clients"].default == ["clickhouse"] + + +# --------------------------------------------------------------------------- +# Existence tests — use the runtime singleton (respects env overrides) +# --------------------------------------------------------------------------- + + +@DATA_PRESENT +def test_geojson_path_exists() -> None: + """Test that the geojson_path directory exists.""" + from wattnet.api.settings import settings + + assert ( + settings.geojson_path.is_dir() + ), f"geojson_path does not exist: {settings.geojson_path}" + + +@DATA_PRESENT +def test_geojson_path_contains_geojson_files() -> None: + """Test that the geojson_path directory contains at least one .geojson file.""" + from wattnet.api.settings import settings + + files = list(settings.geojson_path.glob("*.geojson")) + assert files, f"No .geojson files found in {settings.geojson_path}" + + +@DATA_PRESENT +def test_zones_file_path_exists() -> None: + """Test that the zones_file_path file exists.""" + from wattnet.api.settings import settings + + assert ( + settings.zones_file_path.is_file() + ), f"zones_file_path does not exist: {settings.zones_file_path}" + + +@DATA_PRESENT +def test_crossborders_file_path_exists() -> None: + """Test that the crossborders_file_path file exists.""" + from wattnet.api.settings import settings + + assert ( + settings.crossborders_file_path.is_file() + ), f"crossborders_file_path does not exist: {settings.crossborders_file_path}" + + +# --------------------------------------------------------------------------- +# Environment-variable override tests (use WATTNET_API_ prefix) +# --------------------------------------------------------------------------- + + +def test_geojson_path_overridable_via_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Test that geojson_path can be overridden via environment variable.""" + monkeypatch.setenv("WATTNET_API_GEOJSON_PATH", str(tmp_path)) + + from wattnet.api.settings import Settings + + s = Settings() # type: ignore[call-arg] + assert s.geojson_path == tmp_path + + +def test_zones_file_path_overridable_via_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Test that zones_file_path can be overridden via environment variable.""" + fake = tmp_path / "zones.yaml" + monkeypatch.setenv("WATTNET_API_ZONES_FILE_PATH", str(fake)) + + from wattnet.api.settings import Settings + + s = Settings() # type: ignore[call-arg] + assert s.zones_file_path == fake + + +def test_crossborders_file_path_overridable_via_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Test that crossborders_file_path can be overridden via environment variable.""" + fake = tmp_path / "crossborders.yaml" + monkeypatch.setenv("WATTNET_API_CROSSBORDERS_FILE_PATH", str(fake)) + + from wattnet.api.settings import Settings + + s = Settings() # type: ignore[call-arg] + assert s.crossborders_file_path == fake + + +def test_timeseries_step_minutes_overridable_via_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that timeseries_step_minutes can be overridden via environment variable.""" + monkeypatch.setenv("WATTNET_API_TIMESERIES_STEP_MINUTES", "30") + + from wattnet.api.settings import Settings + + s = Settings() # type: ignore[call-arg] + assert s.timeseries_step_minutes == 30 + + +def test_storage_clients_overridable_via_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that storage_clients can be overridden via environment variable.""" + monkeypatch.setenv("WATTNET_API_STORAGE_CLIENTS", '["clickhouse"]') + + from wattnet.api.settings import Settings + + s = Settings() # type: ignore[call-arg] + assert s.storage_clients == ["clickhouse"] diff --git a/tests/unit/utils/test_log.py b/tests/unit/utils/test_log.py new file mode 100644 index 0000000..0b9dbe3 --- /dev/null +++ b/tests/unit/utils/test_log.py @@ -0,0 +1,170 @@ +import logging +from pathlib import Path +from unittest.mock import patch + +import pytest + +from wattnet.api.utils.log import CustomFormatter, _get_level, get, setup_logging + + +@pytest.fixture(autouse=True) +def reset_wattnet_logger(): + """Remove all handlers from 'wattnet' logger between tests.""" + logger = logging.getLogger("wattnet") + yield + logger.handlers.clear() + logger.propagate = True + + +class TestGetLevel: + def test_debug(self): + assert _get_level("debug") == logging.DEBUG + + def test_info(self): + assert _get_level("info") == logging.INFO + + def test_warning(self): + assert _get_level("warning") == logging.WARNING + + def test_error(self): + assert _get_level("error") == logging.ERROR + + def test_critical(self): + assert _get_level("critical") == logging.CRITICAL + + def test_uppercase_input(self): + assert _get_level("DEBUG") == logging.DEBUG + + def test_mixed_case_input(self): + assert _get_level("Warning") == logging.WARNING + + def test_invalid_string_returns_info(self): + assert _get_level("not_a_level") == logging.INFO + + def test_empty_string_returns_info(self): + assert _get_level("") == logging.INFO + + +class TestCustomFormatter: + def _record(self, level=logging.INFO, msg="hello"): + return logging.LogRecord( + name="test", level=level, pathname="", lineno=0, + msg=msg, args=(), exc_info=None, + ) + + def test_format_returns_string(self): + result = CustomFormatter("%(message)s").format(self._record()) + assert isinstance(result, str) + + def test_format_contains_message(self): + result = CustomFormatter("%(message)s").format(self._record(msg="hello world")) + assert "hello world" in result + + def test_format_contains_ansi_color(self): + result = CustomFormatter("%(message)s").format(self._record(level=logging.DEBUG)) + assert "\x1b[" in result + + def test_format_contains_reset(self): + result = CustomFormatter("%(message)s").format(self._record()) + assert "\x1b[0m" in result + + def test_debug_and_error_have_different_colors(self): + fmt = CustomFormatter("%(message)s") + debug = fmt.format(self._record(level=logging.DEBUG)) + error = fmt.format(self._record(level=logging.ERROR)) + assert debug != error + + def test_all_standard_levels_produce_colored_output(self): + fmt = CustomFormatter("%(levelname)s") + for level in (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL): + result = fmt.format(self._record(level=level)) + assert "\x1b[" in result, f"No ANSI code for level {level}" + + +class TestGet: + def test_returns_logger_instance(self): + assert isinstance(get("test.logger"), logging.Logger) + + def test_logger_name_matches(self): + assert get("my.module.name").name == "my.module.name" + + def test_get_triggers_setup_logging(self): + with patch("wattnet.api.utils.log.settings") as mock_settings: + mock_settings.log_level = "INFO" + mock_settings.log_handlers = ["console"] + mock_settings.log_file = None + get("some.module") + assert logging.getLogger("wattnet").handlers + + +class TestSetupLogging: + def test_configures_wattnet_logger(self): + with patch("wattnet.api.utils.log.settings") as mock_settings: + mock_settings.log_level = "INFO" + mock_settings.log_handlers = ["console"] + mock_settings.log_file = None + setup_logging() + assert logging.getLogger("wattnet").handlers + + def test_console_handler_added(self): + with patch("wattnet.api.utils.log.settings") as mock_settings: + mock_settings.log_level = "INFO" + mock_settings.log_handlers = ["console"] + mock_settings.log_file = None + setup_logging() + handler_types = [type(h) for h in logging.getLogger("wattnet").handlers] + assert logging.StreamHandler in handler_types + + def test_no_file_handler_without_file_setting(self): + with patch("wattnet.api.utils.log.settings") as mock_settings: + mock_settings.log_level = "INFO" + mock_settings.log_handlers = ["console", "file"] + mock_settings.log_file = None + setup_logging() + handler_types = [type(h) for h in logging.getLogger("wattnet").handlers] + assert logging.FileHandler not in handler_types + + def test_propagate_disabled(self): + with patch("wattnet.api.utils.log.settings") as mock_settings: + mock_settings.log_level = "INFO" + mock_settings.log_handlers = ["console"] + mock_settings.log_file = None + setup_logging() + assert logging.getLogger("wattnet").propagate is False + + def test_idempotent(self): + with patch("wattnet.api.utils.log.settings") as mock_settings: + mock_settings.log_level = "INFO" + mock_settings.log_handlers = ["console"] + mock_settings.log_file = None + setup_logging() + setup_logging() + assert len(logging.getLogger("wattnet").handlers) == 1 + + def test_file_handler_added_when_log_file_set(self, tmp_path): + log_file = tmp_path / "logs" / "wattnet.log" + with patch("wattnet.api.utils.log.settings") as mock_settings: + mock_settings.log_level = "INFO" + mock_settings.log_handlers = ["file"] + mock_settings.log_file = log_file + setup_logging() + handlers = logging.getLogger("wattnet").handlers + file_handlers = [h for h in handlers if isinstance(h, logging.FileHandler)] + assert file_handlers + assert log_file.exists() + file_handlers[0].close() + + def test_storage_loggers_reach_wattnet_handler(self): + records = [] + + class CapturingHandler(logging.Handler): + def emit(self, record): + records.append(record) + + wattnet_logger = logging.getLogger("wattnet") + wattnet_logger.setLevel(logging.DEBUG) + wattnet_logger.propagate = False + wattnet_logger.addHandler(CapturingHandler()) + + logging.getLogger("wattnet.storage.clients.manager").info("from storage") + assert any("from storage" in r.getMessage() for r in records) diff --git a/tests/unit/utils/test_validation.py b/tests/unit/utils/test_validation.py new file mode 100644 index 0000000..dd7f275 --- /dev/null +++ b/tests/unit/utils/test_validation.py @@ -0,0 +1,369 @@ +""" +Unit tests for wattnet.api.utils.validation module. + +These tests validate each validation function in isolation: +- validate_location_filters: zone/lat-lon mutual exclusion, geo lookup +- validate_time_range: completeness and ordering of start/end +- validate_aggregation_params: aggregate=True requires dates +- make_utc_aware: naive and aware datetime normalisation +- validate_footprint_type, validate_factor_type, validate_production_type +- validate_scope, validate_operational_scope +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from fastapi import HTTPException + +from wattnet.api.utils.validation import ( + make_utc_aware, + validate_aggregation_params, + validate_factor_type, + validate_footprint_type, + validate_impact_type, + validate_location_filters, + validate_operational_scope, + validate_production_type, + validate_scope, + validate_time_range, +) + +_NOW = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) +_LATER = _NOW + timedelta(hours=2) + +_GEO_PATH = "wattnet.api.utils.validation.geo.get_zone_code" + + +# ============================================================ +# validate_location_filters +# ============================================================ + + +def test_zone_only_returns_zone() -> None: + """zone_id with no coordinates must be returned as-is.""" + assert validate_location_filters("ES", None, None) == "ES" + + +def test_all_none_returns_none() -> None: + """No zone, no coordinates must return None.""" + assert validate_location_filters(None, None, None) is None + + +def test_zone_and_lat_raises_400() -> None: + """Providing zone_id together with lat must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_location_filters("ES", 40.0, None) + assert exc.value.status_code == 400 + + +def test_zone_and_lon_raises_400() -> None: + """Providing zone_id together with lon must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_location_filters("ES", None, -3.0) + assert exc.value.status_code == 400 + + +def test_only_lat_raises_400() -> None: + """Providing lat without lon must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_location_filters(None, 40.0, None) + assert exc.value.status_code == 400 + + +def test_only_lon_raises_400() -> None: + """Providing lon without lat must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_location_filters(None, None, -3.0) + assert exc.value.status_code == 400 + + +def test_lat_lon_found_returns_zone() -> None: + """Valid coordinates returning a zone must return that zone code.""" + with patch(_GEO_PATH, return_value="ES"): + result = validate_location_filters(None, 40.0, -3.0) + assert result == "ES" + + +def test_lat_lon_not_found_raises_404() -> None: + """Coordinates with no matching zone must raise 404.""" + with patch(_GEO_PATH, return_value=None): + with pytest.raises(HTTPException) as exc: + validate_location_filters(None, 0.0, 0.0) + assert exc.value.status_code == 404 + + +# ============================================================ +# validate_time_range +# ============================================================ + + +def test_time_range_both_none_passes() -> None: + """Both start and end as None must not raise.""" + validate_time_range(None, None) + + +def test_time_range_valid_range_passes() -> None: + """start < end must not raise.""" + validate_time_range(_NOW, _LATER) + + +def test_time_range_start_without_end_raises_400() -> None: + """start without end must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_time_range(_NOW, None) + assert exc.value.status_code == 400 + + +def test_time_range_end_without_start_raises_400() -> None: + """end without start must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_time_range(None, _LATER) + assert exc.value.status_code == 400 + + +def test_time_range_start_after_end_raises_400() -> None: + """start > end must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_time_range(_LATER, _NOW) + assert exc.value.status_code == 400 + + +# ============================================================ +# validate_aggregation_params +# ============================================================ + + +def test_aggregate_false_no_dates_passes() -> None: + """aggregate=False without dates must not raise.""" + validate_aggregation_params(False, None, None) + + +def test_aggregate_true_with_dates_passes() -> None: + """aggregate=True with both dates must not raise.""" + validate_aggregation_params(True, _NOW, _LATER) + + +def test_aggregate_true_no_start_raises_400() -> None: + """aggregate=True without start must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_aggregation_params(True, None, _LATER) + assert exc.value.status_code == 400 + + +def test_aggregate_true_no_end_raises_400() -> None: + """aggregate=True without end must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_aggregation_params(True, _NOW, None) + assert exc.value.status_code == 400 + + +def test_aggregate_true_no_dates_raises_400() -> None: + """aggregate=True without any dates must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_aggregation_params(True, None, None) + assert exc.value.status_code == 400 + + +# ============================================================ +# make_utc_aware +# ============================================================ + + +def test_make_utc_aware_naive_gets_utc() -> None: + """Naive datetime must be returned with timezone.utc timezone.""" + naive = datetime(2025, 6, 1, 12, 0, 0) + result = make_utc_aware(naive) + assert result.tzinfo is not None + assert result.utcoffset().total_seconds() == 0 + + +def test_make_utc_aware_utc_unchanged() -> None: + """timezone.utc-aware datetime must stay timezone.utc.""" + result = make_utc_aware(_NOW) + assert result.utcoffset().total_seconds() == 0 + + +def test_make_utc_aware_non_utc_converted() -> None: + """Non-timezone.utc aware datetime must be converted to timezone.utc.""" + cet = timezone(timedelta(hours=2)) + dt_cet = datetime(2025, 6, 1, 14, 0, 0, tzinfo=cet) + result = make_utc_aware(dt_cet) + assert result.utcoffset().total_seconds() == 0 + assert result.hour == 12 + + +# ============================================================ +# validate_footprint_type +# ============================================================ + + +def test_footprint_type_none_passes() -> None: + """None footprint_type must not raise.""" + validate_footprint_type(None) + + +def test_footprint_type_carbon_passes() -> None: + """'carbon' must be accepted.""" + validate_footprint_type("carbon") + + +def test_footprint_type_water_passes() -> None: + """'water' must be accepted.""" + validate_footprint_type("water") + + +def test_footprint_type_invalid_raises_400() -> None: + """Unknown footprint_type must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_footprint_type("nuclear") + assert exc.value.status_code == 400 + + +# ============================================================ +# validate_impact_type +# ============================================================ + + +def test_impact_type_none_passes() -> None: + """None impact_type must not raise.""" + validate_impact_type(None) + + +def test_impact_type_water_passes() -> None: + """'water' must be accepted.""" + validate_impact_type("water") + + +def test_impact_type_case_insensitive_passes() -> None: + """Impact type check must be case-insensitive.""" + validate_impact_type("Water") + + +def test_impact_type_invalid_raises_400() -> None: + """Unknown impact_type must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_impact_type("nuclear") + assert exc.value.status_code == 400 + + +# ============================================================ +# validate_factor_type +# ============================================================ + + +def test_factor_type_none_passes() -> None: + """None factor_type must not raise.""" + validate_factor_type(None) + + +def test_factor_type_carbon_passes() -> None: + """'carbon' must be accepted.""" + validate_factor_type("carbon") + + +def test_factor_type_water_passes() -> None: + """'water' must be accepted.""" + validate_factor_type("water") + + +def test_factor_type_case_insensitive_passes() -> None: + """Factor type check must be case-insensitive.""" + validate_factor_type("Carbon") + + +def test_factor_type_invalid_raises_400() -> None: + """Unknown factor_type must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_factor_type("methane") + assert exc.value.status_code == 400 + + +# ============================================================ +# validate_production_type +# ============================================================ + + +def test_production_type_none_passes() -> None: + """None production_type must not raise.""" + validate_production_type(None) + + +def test_production_type_solar_passes() -> None: + """'solar' must be accepted.""" + validate_production_type("solar") + + +def test_production_type_wind_offshore_passes() -> None: + """'wind_offshore' must be accepted.""" + validate_production_type("wind_offshore") + + +def test_production_type_case_insensitive_passes() -> None: + """Production type check must be case-insensitive.""" + validate_production_type("Solar") + + +def test_production_type_invalid_raises_400() -> None: + """Unknown production_type must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_production_type("fusion") + assert exc.value.status_code == 400 + + +# ============================================================ +# validate_scope +# ============================================================ + + +def test_scope_none_passes() -> None: + """None scope must not raise.""" + validate_scope(None) + + +def test_scope_operational_passes() -> None: + """'operational' must be accepted.""" + validate_scope("operational") + + +def test_scope_life_cycle_passes() -> None: + """'life-cycle' must be accepted.""" + validate_scope("life-cycle") + + +def test_scope_invalid_raises_400() -> None: + """Unknown scope must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_scope("political") + assert exc.value.status_code == 400 + + +# ============================================================ +# validate_operational_scope +# ============================================================ + + +def test_operational_scope_none_passes() -> None: + """None scope must not raise.""" + validate_operational_scope(None) + + +def test_operational_scope_operational_passes() -> None: + """'operational' must be accepted.""" + validate_operational_scope("operational") + + +def test_operational_scope_life_cycle_raises_400() -> None: + """'life-cycle' is not allowed for operational-only endpoints.""" + with pytest.raises(HTTPException) as exc: + validate_operational_scope("life-cycle") + assert exc.value.status_code == 400 + + +def test_operational_scope_invalid_raises_400() -> None: + """Unknown scope must raise 400.""" + with pytest.raises(HTTPException) as exc: + validate_operational_scope("global") + assert exc.value.status_code == 400 diff --git a/tox.ini b/tox.ini index 984ab3a..6703819 100644 --- a/tox.ini +++ b/tox.ini @@ -1,51 +1,43 @@ [tox] min_version = 4.3.3 isolated_build = true -skipsdist = true envlist = - py3{8,9,10,11,12,13} + py3{10,11,12,13,14} flake8 - black + format bandit mypy + deptry pypi + wheel [gh-actions] python = - 3.8: py38 - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 - 3.13: py313, flake8, black, bandit, mypy, pypi + 3.13: py313 + 3.14: py314, flake8, format, bandit, mypy, deptry, pypi, wheel [base] -python = python3.13 +python = python3.14 package = wattnet.api src_path = wattnet [testenv] usedevelop = true -basepython = python3 allowlist_externals = poetry find - rm - mkdir setenv = VIRTUAL_ENV={envdir} LC_ALL=en_US.utf-8 commands_pre = poetry -V - poetry sync --no-root + poetry sync --no-root --with test --no-interaction commands = find . -type f -name "*.pyc" -delete - -[testenv:py38] -basepython = python3.8 - -[testenv:py39] -basepython = python3.9 + poetry run pytest tests/ -v --cov=wattnet --cov-report=term-missing [testenv:py310] basepython = python3.10 @@ -59,6 +51,9 @@ basepython = python3.12 [testenv:py313] basepython = python3.13 +[testenv:py314] +basepython = python3.14 + [flake8] max-line-length = 88 show-source = true @@ -67,6 +62,7 @@ exclude = .venv .git .tox + env dist docs build @@ -74,31 +70,76 @@ exclude = *egg [testenv:flake8] +skip_install = true basepython = {[base]python} +commands_pre = + poetry sync --no-root --only lint --no-interaction commands = poetry run flake8 {[base]src_path} -[testenv:black] +[testenv:format] +skip_install = true basepython = {[base]python} +commands_pre = + poetry sync --no-root --only format --no-interaction commands = + poetry run isort --check --diff {[base]src_path} poetry run black --check --diff {[base]src_path} [testenv:bandit] +skip_install = true basepython = {[base]python} +commands_pre = + poetry sync --no-root --only security --no-interaction commands = - poetry run bandit -r {[base]src_path} -s B110,B410 + poetry run bandit -r {[base]src_path} [testenv:mypy] description = Static type checks basepython = {[base]python} +commands_pre = + poetry sync --no-root --with types --no-interaction +commands = + poetry run mypy -p {[base]package} + +[testenv:integration] +description = Integration tests against the full FastAPI app (storage mocked) +basepython = {[base]python} +allowlist_externals = + poetry +setenv = + VIRTUAL_ENV={envdir} + LC_ALL=en_US.utf-8 +commands_pre = + poetry sync --no-root --with test --no-interaction +commands = + poetry run pytest tests/integration/ -v --tb=short + +[testenv:deptry] +description = Check for unused, missing, or transitive dependencies +usedevelop = true +basepython = {[base]python} +deps = deptry>=0.23 +commands_pre = commands = - poetry run mypy \ - --config-file mypy.ini \ - -p {[base]package} \ - --explicit-package-bases + deptry {[base]src_path} [testenv:pypi] description = Dry-run PyPI publish +skip_install = true +basepython = {[base]python} +commands_pre = + poetry sync --no-root --only release --no-interaction +commands = + poetry build + poetry publish --dry-run --no-interaction + +[testenv:wheel] +description = Build wheel and verify data files are bundled +skip_install = true basepython = {[base]python} +allowlist_externals = poetry +commands_pre = + poetry build --output {envtmpdir}/dist commands = - poetry publish --build --dry-run + python scripts/check_wheel.py {envtmpdir}/dist diff --git a/wattnet/api/__init__.py b/wattnet/api/__init__.py index 9ee3f6b..902b672 100644 --- a/wattnet/api/__init__.py +++ b/wattnet/api/__init__.py @@ -1,5 +1,8 @@ """API application for wattnet.""" +from importlib.metadata import version + from wattnet.api.app import versioned_app as app -__all__ = ["app"] +__version__ = version("wattnet-api") +__all__ = ["app", "__version__"] diff --git a/wattnet/api/app.py b/wattnet/api/app.py index c3116bf..5c315bd 100644 --- a/wattnet/api/app.py +++ b/wattnet/api/app.py @@ -7,6 +7,8 @@ # Documentation with ReDoc: http://localhost:8000/redoc +from pathlib import Path + import uvicorn from fastapi import FastAPI from fastapi.responses import FileResponse @@ -20,11 +22,14 @@ # Get logger LOG = log.get(__name__) +# Get the directory where this file is located for relative paths +_API_DIR = Path(__file__).parent + summary = ( "A comprehensive RESTful API for integrating wattnet into your applications. " "Query real-time, historical, and forecasted electricity footprints, " "create dashboards, automate analyses, and extend wattnet’s capabilities. " - "Fully documented with OpenAPI 3.1 and secured by OAuth 2.0." + "Fully documented with OpenAPI 3.1." ) description = ( @@ -131,27 +136,28 @@ # Add favicon route versioned_app.mount( - "/static", StaticFiles(directory="wattnet/api/static"), name="static" + "/static", StaticFiles(directory=str(_API_DIR / "static")), name="static" ) @versioned_app.get("/favicon.ico", include_in_schema=False) def favicon() -> FileResponse: """Route for favicon.ico.""" - return FileResponse("wattnet/api/static/favicon.ico") + return FileResponse(_API_DIR / "static" / "favicon.ico") def main() -> None: """Start the wattnet API server.""" # Run the server LOG.info("Starting wattnet RESTful API server...") + LOG.debug(f"Settings: {settings}") uvicorn.run( versioned_app, - host=settings.api_host, - port=settings.api_port, - log_level="info" if not settings.api_debug else "debug", + host=settings.host, + port=settings.port, + log_level="info" if not settings.debug else "debug", ) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover main() diff --git a/wattnet/api/dependencies.py b/wattnet/api/dependencies.py index c23ee9b..d575c55 100644 --- a/wattnet/api/dependencies.py +++ b/wattnet/api/dependencies.py @@ -1,7 +1,5 @@ """Dependency management for the wattnet API application.""" -from wattnet.storage.repository import MetricsRepository - from wattnet.api.service.exports import ExportService from wattnet.api.service.factors import FactorService from wattnet.api.service.flow_share import FlowShareService @@ -12,14 +10,21 @@ from wattnet.api.service.impacts import ImpactService from wattnet.api.service.imports import ImportService from wattnet.api.service.load import LoadService -from wattnet.api.service.mix_share import MixShareService from wattnet.api.service.mix import MixService +from wattnet.api.service.mix_share import MixShareService from wattnet.api.service.scores import ScoreService from wattnet.api.service.zones import ZoneService -from wattnet.api.settings import settings +from wattnet.api.settings import plugin_settings, settings +from wattnet.storage import MetricsRepository, StorageConfig + +storage_config = StorageConfig( + timeseries_step_minutes=settings.timeseries_step_minutes, + storage_clients=settings.storage_clients, + plugin_configs={name: s.model_dump() for name, s in plugin_settings.items()}, +) -# Create a MetricsRepository instance -metrics_repo = MetricsRepository() +# Create a MetricsRepository instance with injected config +metrics_repo = MetricsRepository(storage_config) # Create Service instances generation_service = GenerationService(metrics_repo) diff --git a/wattnet/api/models/impact.py b/wattnet/api/models/impact.py index 7dc3dc4..0c728a9 100644 --- a/wattnet/api/models/impact.py +++ b/wattnet/api/models/impact.py @@ -20,9 +20,7 @@ class ImpactBase(BaseModel): impact_type: ImpactType = Field(..., description="Type of impact (water)") scope: ImpactScope = Field(..., description="Scope of the impact (operational)") zone: str = Field(..., description="wattnet zone code") - unit: ImpactUnit = Field( - ..., description="Unit of the impact value (stress-l/kWh)" - ) + unit: ImpactUnit = Field(..., description="Unit of the impact value (stress-l/kWh)") coverage: CoverageType = Field( ..., description="Coverage type of the impact (global or local)" ) diff --git a/wattnet/api/routers/v1/impact_share.py b/wattnet/api/routers/v1/impact_share.py index 0d4944c..96d4557 100644 --- a/wattnet/api/routers/v1/impact_share.py +++ b/wattnet/api/routers/v1/impact_share.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import List, Optional -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Query from fastapi_versioning import version from wattnet.api.dependencies import impact_share_service @@ -114,15 +114,10 @@ def get_impact_share( source_zone = validation.validate_location_filters( source_zone, source_lat, source_lon ) + validation.validate_impact_type(impact_type) validation.validate_time_range(start, end) validation.validate_operational_scope(scope) - if impact_type and impact_type.lower() != "water": - raise HTTPException( - status_code=400, - detail=f"Invalid impact_type '{impact_type}'. Valid: [water]", - ) - return impact_share_service.get_impact_share( zone=zone.upper() if zone else None, source=source_zone.upper() if source_zone else None, diff --git a/wattnet/api/routers/v1/impacts.py b/wattnet/api/routers/v1/impacts.py index d2e4eb1..b0c9ced 100644 --- a/wattnet/api/routers/v1/impacts.py +++ b/wattnet/api/routers/v1/impacts.py @@ -123,18 +123,11 @@ def get_impacts( end = validation.make_utc_aware(end) zone = validation.validate_location_filters(zone, lat, lon) + validation.validate_impact_type(impact_type) validation.validate_operational_scope(scope) validation.validate_time_range(start, end) validation.validate_aggregation_params(aggregate, start, end) - if impact_type and impact_type.lower() != "water": - from fastapi import HTTPException - - raise HTTPException( - status_code=400, - detail=f"Invalid impact_type '{impact_type}'. Valid: [water]", - ) - return impact_service.get_impacts( zone=zone.upper() if zone else None, impact_type=impact_type.lower() if impact_type else "water", diff --git a/wattnet/api/routers/v1/status.py b/wattnet/api/routers/v1/status.py index 9055ce4..4b75546 100644 --- a/wattnet/api/routers/v1/status.py +++ b/wattnet/api/routers/v1/status.py @@ -24,8 +24,8 @@ def check_storage_system() -> bool: LOG.error("Storage system URL missing in config") return False try: - requests.get(storage_url, timeout=5) - return True + r = requests.get(storage_url, timeout=5) + return r.status_code == 200 except requests.RequestException as e: LOG.error(f"Storage system check failed: {e}") return False @@ -141,7 +141,7 @@ def check_epias() -> str: return "down" -@router.get("/status") +@router.get("") @version(1) async def status() -> dict: """Check the health of the storage system and external APIs. @@ -157,7 +157,7 @@ async def status() -> dict: } -@router.get("/status/storage") +@router.get("/storage") @version(1) async def status_storage() -> dict: """Check the health of the storage system. @@ -168,7 +168,7 @@ async def status_storage() -> dict: return {"storage": check_storage()} -@router.get("/status/entso-e") +@router.get("/entso-e") @version(1) async def status_entsoe() -> dict: """Check the health of the ENTSOE API. @@ -179,7 +179,7 @@ async def status_entsoe() -> dict: return {"entso-e": check_entsoe()} -@router.get("/status/elexon") +@router.get("/elexon") @version(1) async def status_elexon() -> dict: """Check the health of the Elexon API. @@ -190,7 +190,7 @@ async def status_elexon() -> dict: return {"elexon": check_elexon()} -@router.get("/status/epias") +@router.get("/epias") @version(1) async def status_epias() -> dict: """Check the health of the EPIAS API. diff --git a/wattnet/api/routers/v1/zones.py b/wattnet/api/routers/v1/zones.py index 55cb90f..e4ca11e 100644 --- a/wattnet/api/routers/v1/zones.py +++ b/wattnet/api/routers/v1/zones.py @@ -2,7 +2,8 @@ from typing import List -from fastapi import APIRouter +import yaml +from fastapi import APIRouter, HTTPException from fastapi_versioning import version from wattnet.api.dependencies import zone_service @@ -30,4 +31,7 @@ @version(1) def get_zones() -> List[Zone]: """Return all zones with neighbours.""" - return zone_service.get_zones() + try: + return zone_service.get_zones() + except (ValueError, yaml.YAMLError) as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/wattnet/api/service/exports.py b/wattnet/api/service/exports.py index 3d0efd4..e6a52f0 100644 --- a/wattnet/api/service/exports.py +++ b/wattnet/api/service/exports.py @@ -3,12 +3,11 @@ from datetime import datetime from typing import List, Optional -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.exports import Export, ExportBlock, ExportSeries from wattnet.api.service.operations import group_metrics_by_metadata from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) @@ -16,7 +15,7 @@ class ExportService: """Service to handle export metrics.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the ExportService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. @@ -24,7 +23,7 @@ def __init__(self, metrics_repo: Optional[MetricsRepository] = None): :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing ExportService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_exports( self, diff --git a/wattnet/api/service/factors.py b/wattnet/api/service/factors.py index 22784d8..5e271ce 100644 --- a/wattnet/api/service/factors.py +++ b/wattnet/api/service/factors.py @@ -3,9 +3,6 @@ from datetime import datetime from typing import List, Optional, Union -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.factor import Factor, FactorAggregate, FactorSeries from wattnet.api.service.operations import ( build_time_series, @@ -13,6 +10,8 @@ group_metrics_by_metadata, ) from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) @@ -20,7 +19,7 @@ class FactorService: """Service to handle factor metrics.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the FactorService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. @@ -28,7 +27,7 @@ def __init__(self, metrics_repo: Optional[MetricsRepository] = None): :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing FactorService...") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_factors( self, diff --git a/wattnet/api/service/flow_share.py b/wattnet/api/service/flow_share.py index 7a8c779..11f4c66 100644 --- a/wattnet/api/service/flow_share.py +++ b/wattnet/api/service/flow_share.py @@ -3,12 +3,11 @@ from datetime import datetime from typing import List, Optional -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.flow_share import FlowShare, FlowShareBlock, FlowShareSeries -from wattnet.api.service.operations import group_metrics_by_metadata +from wattnet.api.service.operations import build_time_series, group_metrics_by_metadata from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) @@ -16,7 +15,7 @@ class FlowShareService: """Service to handle flow share metrics.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the FlowShareService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. @@ -24,7 +23,7 @@ def __init__(self, metrics_repo: Optional[MetricsRepository] = None): :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing FlowShareService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_flow_share( self, @@ -86,9 +85,7 @@ def _group_metrics(self, metrics: List[Metric]) -> List[FlowShare]: results = [] - for zone_key, zone_metrics in zone_groups.items(): - # Convert tuple to string if needed - zone_str = zone_key[0] if isinstance(zone_key, tuple) else zone_key + for (zone_str,), zone_metrics in zone_groups.items(): # 2) Group by (valid, zone_status) => FlowShareSeries series_groups = group_metrics_by_metadata( @@ -104,26 +101,16 @@ def _group_metrics(self, metrics: List[Metric]) -> List[FlowShare]: block_list: List[FlowShareBlock] = [] - for destination_key, block_metrics in block_groups.items(): - # Convert tuple to string if needed - destination_str = ( - destination_key[0] - if isinstance(destination_key, tuple) - else destination_key + for (destination,), block_metrics in block_groups.items(): + block_list.append( + FlowShareBlock( + destination=( + destination if destination is not None else "unknown" + ), + values=build_time_series(block_metrics), + ) ) - values = sorted( - [(m.timestamp, m.value) for m in block_metrics], - key=lambda x: x[0], - ) - - block = FlowShareBlock( - destination=destination_str, - values=values, - ) - - block_list.append(block) - flow_series = FlowShareSeries( valid=valid, zone_status=zone_status, diff --git a/wattnet/api/service/footprint_share.py b/wattnet/api/service/footprint_share.py index 9a378be..83b37de 100644 --- a/wattnet/api/service/footprint_share.py +++ b/wattnet/api/service/footprint_share.py @@ -3,16 +3,15 @@ from datetime import datetime from typing import List, Optional -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.footprint_share import ( FootprintShare, FootprintShareBlock, FootprintShareSeries, ) -from wattnet.api.service.operations import group_metrics_by_metadata +from wattnet.api.service.operations import build_time_series, group_metrics_by_metadata from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) @@ -20,7 +19,7 @@ class FootprintShareService: """Service to handle footprint share metrics.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the FootprintShareService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. @@ -28,7 +27,7 @@ def __init__(self, metrics_repo: Optional[MetricsRepository] = None): :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing FootprintShareService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_footprint_share( self, @@ -82,6 +81,8 @@ def get_footprint_share( labels=labels, ) + metrics = [m for m in metrics if m.value is not None] + if not metrics: return [] @@ -115,18 +116,13 @@ def _group_metrics(self, metrics: List[Metric]) -> List[FootprintShare]: for source_key, block_metrics in block_groups.items(): source_str = ( - source_key[0] if isinstance(source_key, tuple) else source_key - ) - if source_str is None: - source_str = "unknown" - - values = sorted( - [(m.timestamp, m.value) for m in block_metrics], - key=lambda x: x[0], + source_key[0] if source_key[0] is not None else "unknown" ) - block_list.append( - FootprintShareBlock(source=source_str, values=values) + FootprintShareBlock( + source=source_str, + values=build_time_series(block_metrics), + ) ) series_list.append( diff --git a/wattnet/api/service/footprints.py b/wattnet/api/service/footprints.py index 7f514fc..68491e1 100644 --- a/wattnet/api/service/footprints.py +++ b/wattnet/api/service/footprints.py @@ -3,33 +3,26 @@ from datetime import datetime from typing import List, Optional, cast -from typing_extensions import Literal -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.footprint import Footprint, FootprintAggregate, FootprintSeries from wattnet.api.service.operations import ( + ZoneStatus, build_time_series, compute_time_weighted_average, group_metrics_by_metadata, + is_valid_agg, + resolve_zone_status, ) from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) -ZoneStatus = Literal["complete", "preview", "missing"] - -priority_map = { - "missing": 0, - "preview": 1, - "complete": 2, -} - class FootprintService: """Service to handle footprint metrics for wattnet.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the FootprintService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. @@ -37,7 +30,7 @@ def __init__(self, metrics_repo: Optional[MetricsRepository] = None): :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing FootprintService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_footprints( self, @@ -140,21 +133,8 @@ def _aggregate_metrics( for (footprint_type, scope, zone), mlist in grouped.items(): value_agg = compute_time_weighted_average(mlist, start, end) - - valid_agg = all( - m.metadata.get("valid", "").lower() == "true" for m in mlist - ) - zone_status_values = [ - m.metadata.get("zone_status", "missing") for m in mlist - ] - min_priority_value = min( - priority_map.get(zs, 0) for zs in zone_status_values - ) - min_priority = [ - k for k, v in priority_map.items() if v == min_priority_value - ][0] - - zone_status: ZoneStatus = cast(ZoneStatus, min_priority) + valid_agg = is_valid_agg(mlist) + zone_status = resolve_zone_status(mlist) aggregates.append( FootprintAggregate( @@ -207,7 +187,7 @@ def _group_metrics_series( validity_subgroup: dict[tuple[bool, ZoneStatus], list[Metric]] = {} for m in mlist: key = ( - m.metadata.get("valid", True), + m.metadata.get("valid", "true").lower() == "true", cast(ZoneStatus, m.metadata.get("zone_status", "missing")), ) validity_subgroup.setdefault(key, []).append(m) diff --git a/wattnet/api/service/generation.py b/wattnet/api/service/generation.py index 9906f54..4d73f66 100644 --- a/wattnet/api/service/generation.py +++ b/wattnet/api/service/generation.py @@ -3,12 +3,11 @@ from datetime import datetime from typing import List, Optional -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.generation import Generation, GenerationSeries, ProductionBlock -from wattnet.api.service.operations import group_metrics_by_metadata +from wattnet.api.service.operations import build_time_series, group_metrics_by_metadata from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) @@ -16,7 +15,7 @@ class GenerationService: """Service to handle generation metrics for wattnet.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the GenerationService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. @@ -24,7 +23,7 @@ def __init__(self, metrics_repo: Optional[MetricsRepository] = None): :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing GenerationService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_generation( self, @@ -111,21 +110,15 @@ def _group_metrics(self, metrics: List[Metric]) -> List[Generation]: data_state, datasource, ), block_metrics in block_groups.items(): - - values = sorted( - [(m.timestamp, m.value) for m in block_metrics], - key=lambda x: x[0], - ) - - block = ProductionBlock( - production_type=production_type, - data_state=data_state, - datasource=datasource, - values=values, + blocks.append( + ProductionBlock( + production_type=production_type, + data_state=data_state, + datasource=datasource, + values=build_time_series(block_metrics), + ) ) - blocks.append(block) - generation_series = GenerationSeries( valid=valid, zone_status=zone_status, diff --git a/wattnet/api/service/geo.py b/wattnet/api/service/geo.py index a181cc4..01428f7 100644 --- a/wattnet/api/service/geo.py +++ b/wattnet/api/service/geo.py @@ -11,9 +11,11 @@ from wattnet.api.settings import settings from wattnet.api.utils import log -# Get logger LOG = log.get(__name__) +_GDF_CACHE: dict[Path, gpd.GeoDataFrame] = {} +_FILENAMES_CACHE: dict[Path, list[str]] = {} + async def check_file_contains_point( filename: str, folder: Path, point: Point @@ -38,8 +40,9 @@ async def check_file_contains_point( try: def read_and_check() -> Optional[str]: - gdf = gpd.read_file(path) - if gdf.contains(point).any(): + if path not in _GDF_CACHE: + _GDF_CACHE[path] = gpd.read_file(path) + if _GDF_CACHE[path].contains(point).any(): return filename.rsplit(".", 1)[0] return None @@ -67,7 +70,9 @@ async def find_zone_async( :rtype: str | None """ point = Point(lon, lat) - filenames = os.listdir(geojson_folder) + if geojson_folder not in _FILENAMES_CACHE: + _FILENAMES_CACHE[geojson_folder] = os.listdir(geojson_folder) + filenames = _FILENAMES_CACHE[geojson_folder] semaphore = trio.Semaphore(10) # Cap concurrency result_holder: dict[str, Optional[str]] = {"zone": None} diff --git a/wattnet/api/service/impact_share.py b/wattnet/api/service/impact_share.py index a5c2d76..c268810 100644 --- a/wattnet/api/service/impact_share.py +++ b/wattnet/api/service/impact_share.py @@ -3,33 +3,33 @@ from datetime import datetime from typing import List, Optional -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.impact_share import ( ImpactShare, ImpactShareBlock, ImpactShareSeries, ) -from wattnet.api.service.operations import group_metrics_by_metadata +from wattnet.api.service.operations import build_time_series, group_metrics_by_metadata from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) + class ImpactShareService: """Service to handle impact share metrics for wattnet. Water impact share is served from the impact_share table. """ - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the ImpactShareService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing ImpactShareService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_impact_share( self, @@ -64,7 +64,7 @@ def get_impact_share( :return: List of ImpactShare objects matching the filters. :rtype: List[ImpactShare] """ - results = [] + results: List[ImpactShare] = [] if impact_type not in (None, "water"): return results @@ -96,6 +96,8 @@ def _get_water_impact_share( metric_name="impact_share", start=start, end=end, labels=labels ) + metrics = [m for m in metrics if m.value is not None] + if not metrics: return [] @@ -123,17 +125,13 @@ def _group_metrics(self, metrics: List[Metric]) -> List[ImpactShare]: for source_key, block_metrics in block_groups.items(): source_str = ( - source_key[0] if isinstance(source_key, tuple) else source_key - ) - if source_str is None: - source_str = "unknown" - - values = sorted( - [(m.timestamp, m.value) for m in block_metrics], - key=lambda x: x[0], + source_key[0] if source_key[0] is not None else "unknown" ) block_list.append( - ImpactShareBlock(source=source_str, values=values) + ImpactShareBlock( + source=source_str, + values=build_time_series(block_metrics), + ) ) series_list.append( diff --git a/wattnet/api/service/impacts.py b/wattnet/api/service/impacts.py index 1d98113..b709626 100644 --- a/wattnet/api/service/impacts.py +++ b/wattnet/api/service/impacts.py @@ -3,28 +3,21 @@ from datetime import datetime from typing import List, Optional, cast -from typing_extensions import Literal -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.impact import Impact, ImpactAggregate, ImpactSeries from wattnet.api.service.operations import ( + ZoneStatus, build_time_series, compute_time_weighted_average, group_metrics_by_metadata, + is_valid_agg, + resolve_zone_status, ) from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) -ZoneStatus = Literal["complete", "preview", "missing"] - -priority_map = { - "missing": 0, - "preview": 1, - "complete": 2, -} - class ImpactService: """Service to handle environmental impact metrics for wattnet. @@ -32,14 +25,14 @@ class ImpactService: Water impact is read from the dedicated impact tables. """ - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the ImpactService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing ImpactService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_impacts( self, @@ -79,12 +72,11 @@ def get_impacts( :rtype: List """ results = [] + normalized_type = impact_type.lower() if impact_type else None - if impact_type in (None, "water"): + if normalized_type in (None, "water"): results.extend( - self._get_water_impacts( - zone, scope, start, end, aggregate, use_global - ) + self._get_water_impacts(zone, scope, start, end, aggregate, use_global) ) return results @@ -137,19 +129,8 @@ def _aggregate_metrics( for (i_type, scope, zone), mlist in grouped.items(): value_agg = compute_time_weighted_average(mlist, start, end) - valid_agg = all( - m.metadata.get("valid", "").lower() == "true" for m in mlist - ) - zone_status_values = [ - m.metadata.get("zone_status", "missing") for m in mlist - ] - min_priority_value = min( - priority_map.get(zs, 0) for zs in zone_status_values - ) - zone_status: ZoneStatus = cast( - ZoneStatus, - [k for k, v in priority_map.items() if v == min_priority_value][0], - ) + valid_agg = is_valid_agg(mlist) + zone_status = resolve_zone_status(mlist) unit = mlist[0].metadata.get("unit", "unknown") @@ -187,7 +168,7 @@ def _group_metrics_series( validity_subgroup: dict[tuple, list[Metric]] = {} for m in mlist: key = ( - m.metadata.get("valid", True), + m.metadata.get("valid", "true").lower() == "true", cast(ZoneStatus, m.metadata.get("zone_status", "missing")), ) validity_subgroup.setdefault(key, []).append(m) diff --git a/wattnet/api/service/imports.py b/wattnet/api/service/imports.py index 0f25688..0b5adc6 100644 --- a/wattnet/api/service/imports.py +++ b/wattnet/api/service/imports.py @@ -3,12 +3,11 @@ from datetime import datetime from typing import List, Optional -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.imports import Import, ImportBlock, ImportSeries from wattnet.api.service.operations import group_metrics_by_metadata from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) @@ -16,7 +15,7 @@ class ImportService: """Service to handle import metrics for wattnet.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the ImportService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. @@ -24,7 +23,7 @@ def __init__(self, metrics_repo: Optional[MetricsRepository] = None): :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing ImportService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_imports( self, diff --git a/wattnet/api/service/load.py b/wattnet/api/service/load.py index ffa9908..eab2b2d 100644 --- a/wattnet/api/service/load.py +++ b/wattnet/api/service/load.py @@ -3,12 +3,11 @@ from datetime import datetime from typing import List, Optional -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.load import Load, LoadBlock, LoadSeries from wattnet.api.service.operations import group_metrics_by_metadata from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) @@ -16,7 +15,7 @@ class LoadService: """Service to handle load (total electricity demand) metrics for wattnet.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the LoadService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. @@ -24,7 +23,7 @@ def __init__(self, metrics_repo: Optional[MetricsRepository] = None): :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing LoadService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_load( self, diff --git a/wattnet/api/service/mix.py b/wattnet/api/service/mix.py index f12655f..bd20eea 100644 --- a/wattnet/api/service/mix.py +++ b/wattnet/api/service/mix.py @@ -3,12 +3,11 @@ from datetime import datetime from typing import List, Optional -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.mix import Mix, MixBlock, MixSeries from wattnet.api.service.operations import group_metrics_by_metadata from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) @@ -16,10 +15,10 @@ class MixService: """Service to handle mix generation metrics for wattnet.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the MixService with a MetricsRepository.""" LOG.info("Initializing MixService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_mix( self, diff --git a/wattnet/api/service/mix_share.py b/wattnet/api/service/mix_share.py index e65a597..acb1457 100644 --- a/wattnet/api/service/mix_share.py +++ b/wattnet/api/service/mix_share.py @@ -3,12 +3,11 @@ from datetime import datetime from typing import List, Optional -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.mix_share import MixShare, MixShareBlock, MixShareSeries -from wattnet.api.service.operations import group_metrics_by_metadata +from wattnet.api.service.operations import build_time_series, group_metrics_by_metadata from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) @@ -16,14 +15,14 @@ class MixShareService: """Service to handle mix share metrics for Wattnet.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the MixShareService with a metrics repository. :param metrics_repo: Optional MetricsRepository instance for database access. If not provided, a new instance will be created. :type metrics_repo: MetricsRepository, optional """ - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_mix_share( self, @@ -64,6 +63,8 @@ def get_mix_share( labels=labels, ) + metrics = [m for m in metrics if m.value is not None] + if not metrics: return [] @@ -83,8 +84,7 @@ def _group_metrics(self, metrics: List[Metric]) -> List[MixShare]: # Group by destination zone zone_groups = group_metrics_by_metadata(metrics, ["zone"]) - for zone_key, zone_metrics in zone_groups.items(): - zone_str = zone_key[0] if isinstance(zone_key, tuple) else zone_key + for (zone_str,), zone_metrics in zone_groups.items(): # Group by series attributes (valid + zone_status) series_groups = group_metrics_by_metadata( @@ -98,18 +98,14 @@ def _group_metrics(self, metrics: List[Metric]) -> List[MixShare]: block_groups = group_metrics_by_metadata(series_metrics, ["source"]) block_list: List[MixShareBlock] = [] - for origin_key, block_metrics in block_groups.items(): - origin_str = ( - origin_key[0] if isinstance(origin_key, tuple) else origin_key - ) - - values = sorted( - [(m.timestamp, m.value) for m in block_metrics], - key=lambda x: x[0], + for (origin,), block_metrics in block_groups.items(): + block_list.append( + MixShareBlock( + origin=origin if origin is not None else "unknown", + values=build_time_series(block_metrics), + ) ) - block_list.append(MixShareBlock(origin=origin_str, values=values)) - series_list.append( MixShareSeries( valid=valid, diff --git a/wattnet/api/service/operations.py b/wattnet/api/service/operations.py index e703c12..a23d1a1 100644 --- a/wattnet/api/service/operations.py +++ b/wattnet/api/service/operations.py @@ -2,10 +2,35 @@ from datetime import datetime from decimal import Decimal -from typing import Dict, Iterable, List, Tuple +from typing import Dict, Iterable, List, Optional, Tuple, cast + +from typing_extensions import Literal from wattnet.storage.models import Metric +ZoneStatus = Literal["complete", "preview", "missing"] + +_PRIORITY_MAP: Dict[str, int] = { + "missing": 0, + "preview": 1, + "complete": 2, +} + + +def resolve_zone_status(metrics: List[Metric]) -> ZoneStatus: + """Return the minimum-priority zone_status across a list of metrics.""" + if not metrics: + return "missing" + zone_statuses = [m.metadata.get("zone_status", "missing") for m in metrics] + min_priority = min(_PRIORITY_MAP.get(zs, 0) for zs in zone_statuses) + result = [k for k, v in _PRIORITY_MAP.items() if v == min_priority][0] + return cast(ZoneStatus, result) + + +def is_valid_agg(metrics: List[Metric]) -> bool: + """Return True if all metrics have valid='true' (absent key treated as valid).""" + return all(m.metadata.get("valid", "true").lower() == "true" for m in metrics) + def group_metrics_by_metadata( metrics: Iterable[Metric], @@ -54,32 +79,46 @@ def compute_time_weighted_average( metrics = sorted(metrics, key=lambda x: x.timestamp) + # Pre-convert to Decimal once; derive precision in the same pass + dvals: List[Optional[Decimal]] = [] + precision = 0 + for m in metrics: + if m.value is not None: + v = Decimal(str(m.value)) + precision = max(precision, abs(int(v.as_tuple().exponent))) + dvals.append(v) + else: + dvals.append(None) + total_weighted = Decimal("0") total_duration = Decimal("0") # Integrate all but last segment - for m0, m1 in zip(metrics, metrics[1:]): + for (m0, v0), (m1, _) in zip(zip(metrics, dvals), zip(metrics[1:], dvals[1:])): t0 = max(m0.timestamp, start) t1 = min(m1.timestamp, end) duration = Decimal((t1 - t0).total_seconds()) - - if duration > 0: - total_weighted += Decimal(str(m0.value)) * duration + if duration > 0 and v0 is not None: + total_weighted += v0 * duration total_duration += duration # Extend last metric until the end last = metrics[-1] - if last.timestamp < end: + v_last = dvals[-1] + if last.timestamp < end and v_last is not None: t0 = max(last.timestamp, start) duration = Decimal((end - t0).total_seconds()) if duration > 0: - total_weighted += Decimal(str(last.value)) * duration + total_weighted += v_last * duration total_duration += duration if total_duration > 0: - return float(total_weighted / total_duration) + return float(round(total_weighted / total_duration, precision)) - return float(metrics[0].value) + # Fallback: single metric at/after end boundary — return its value. + # Use first non-None decimal; return 0.0 only when all values are absent. + v_first = next((v for v in dvals if v is not None), None) + return float(round(v_first, precision)) if v_first is not None else 0.0 def build_time_series( diff --git a/wattnet/api/service/scores.py b/wattnet/api/service/scores.py index 0b9f019..d73c1ad 100644 --- a/wattnet/api/service/scores.py +++ b/wattnet/api/service/scores.py @@ -3,40 +3,33 @@ from datetime import datetime from typing import List, Optional, cast -from typing_extensions import Literal -from wattnet.storage.models import Metric -from wattnet.storage.repository import MetricsRepository - from wattnet.api.models.score import GreenScore, GreenScoreAggregate, GreenScoreSeries from wattnet.api.service.operations import ( + ZoneStatus, build_time_series, compute_time_weighted_average, group_metrics_by_metadata, + is_valid_agg, + resolve_zone_status, ) from wattnet.api.utils import log +from wattnet.storage.models import Metric +from wattnet.storage.repository import MetricsRepository LOG = log.get(__name__) -ZoneStatus = Literal["complete", "preview", "missing"] - -priority_map = { - "missing": 0, - "preview": 1, - "complete": 2, -} - class ScoreService: """Service to handle GreenScore metrics for wattnet.""" - def __init__(self, metrics_repo: Optional[MetricsRepository] = None): + def __init__(self, metrics_repo: MetricsRepository): """Initialize the ScoreService with a MetricsRepository. :param metrics_repo: Optional MetricsRepository instance. :type metrics_repo: MetricsRepository, optional """ LOG.info("Initializing ScoreService") - self.repo = metrics_repo or MetricsRepository() + self.repo = metrics_repo def get_scores( self, @@ -81,7 +74,7 @@ def get_scores( metrics = self.repo.query_metrics( metric_name=metric_name, start=start, end=end, labels=labels ) - metrics = [m for m in metrics if m.value is not None] + metrics = [m for m in metrics if m.value is not None and m.value >= 0] if not metrics: return [] @@ -105,19 +98,8 @@ def _aggregate_metrics( for (scope, zone), mlist in grouped.items(): value_agg = compute_time_weighted_average(mlist, start, end) - valid_agg = all( - m.metadata.get("valid", "").lower() == "true" for m in mlist - ) - zone_status_values = [ - m.metadata.get("zone_status", "missing") for m in mlist - ] - min_priority_value = min( - priority_map.get(zs, 0) for zs in zone_status_values - ) - zone_status: ZoneStatus = cast( - ZoneStatus, - [k for k, v in priority_map.items() if v == min_priority_value][0], - ) + valid_agg = is_valid_agg(mlist) + zone_status = resolve_zone_status(mlist) aggregates.append( GreenScoreAggregate( @@ -148,7 +130,7 @@ def _group_metrics_series( validity_subgroup: dict[tuple, list[Metric]] = {} for m in mlist: key = ( - m.metadata.get("valid", True), + m.metadata.get("valid", "true").lower() == "true", cast(ZoneStatus, m.metadata.get("zone_status", "missing")), ) validity_subgroup.setdefault(key, []).append(m) diff --git a/wattnet/api/service/zones.py b/wattnet/api/service/zones.py index d86d1bc..23d8fc2 100644 --- a/wattnet/api/service/zones.py +++ b/wattnet/api/service/zones.py @@ -5,12 +5,12 @@ import yaml -from wattnet.api.models.zone import Zone +from wattnet.api.models.zone import Provider, Zone from wattnet.api.utils import log LOG = log.get(__name__) -_PROVIDER_MAP = { +_PROVIDER_MAP: Dict[str, Provider] = { "entsoe": "ENTSO-E", "elexon": "Elexon", "epias": "EPIAS", @@ -26,18 +26,8 @@ def __init__( crossborders_file_path: Path, ): """Initialize the ZoneService with mandatory YAML data files.""" - self.zones_file_path = zones_file_path - self.crossborders_file_path = crossborders_file_path - LOG.info( - "Initialized ZoneService with zones file: %s and crossborders file: %s", - zones_file_path, - crossborders_file_path, - ) - - def get_zones(self) -> List[Zone]: - """Return merged zones list including neighbours.""" - zones_raw = self._read_yaml_list(self.zones_file_path) - crossborders_raw = self._read_yaml_list(self.crossborders_file_path) + zones_raw = self._read_yaml_list(zones_file_path) + crossborders_raw = self._read_yaml_list(crossborders_file_path) neighbours_by_zone: Dict[str, List[str]] = {} for item in crossborders_raw: @@ -60,7 +50,12 @@ def get_zones(self) -> List[Zone]: ) ) - return sorted(zones, key=lambda x: x.zone) + self._zones = sorted(zones, key=lambda x: x.zone) + LOG.info("Initialized ZoneService with %d zones", len(self._zones)) + + def get_zones(self) -> List[Zone]: + """Return all zones with neighbours.""" + return self._zones @staticmethod def _read_yaml_list(path: Path) -> List[dict]: @@ -74,7 +69,7 @@ def _read_yaml_list(path: Path) -> List[dict]: return data @staticmethod - def _normalize_provider(provider: str) -> str: + def _normalize_provider(provider: str) -> Provider: """Normalize provider naming to API output conventions.""" if provider not in _PROVIDER_MAP: raise ValueError(f"Unsupported provider '{provider}'") diff --git a/wattnet/api/settings.py b/wattnet/api/settings.py index d288c6d..b9b1863 100644 --- a/wattnet/api/settings.py +++ b/wattnet/api/settings.py @@ -1,54 +1,69 @@ -"""Settings management for the wattnet API application.""" +"""Settings management for wattnet-api.""" -import os from pathlib import Path from pydantic_settings import BaseSettings, SettingsConfigDict -# Define the base directory of the project +from wattnet.storage.utils.plugin_loader import get_storage_clients_extensions + BASE_DIR = Path(__file__).resolve().parent.parent.parent -# Determine the environment and load corresponding .env file -ENVIRONMENT = os.getenv("WATTNET_ENV", "development") -env_file = BASE_DIR / "config" / f".env.{ENVIRONMENT}" +_ENV_FILES: list[str] = ["/etc/wattnet/api.env", ".env"] + +plugin_settings: dict[str, BaseSettings] = { + name: cls.config_class(_env_file=_ENV_FILES) + for name, cls in get_storage_clients_extensions().items() + if hasattr(cls, "config_class") +} class Settings(BaseSettings): - """Application settings loaded from environment variables.""" + """Application settings for wattnet-api. + + Configuration sources (highest to lowest priority): + 1. Process environment variables (prefixed WATTNET_API_) + 2. /etc/wattnet/api.env (production) + 3. .env at project root (local development, gitignored) + 4. Defaults defined here + """ - # API Server Settings - api_host: str = "localhost" - api_port: int = 8000 - api_debug: bool = True + # Server + host: str = "0.0.0.0" # nosec B104 + port: int = 8000 + workers: int = 1 + debug: bool = False - # GeoJSON File Paths - geojson_path: Path = BASE_DIR / "data" / "zones.geojson" + # GeoJSON + geojson_path: Path = BASE_DIR / "data" / "geojson" + + # Zones (by default, use all ENTSO-E defined zones and crossborders) + zones_file_path: Path = BASE_DIR / "data" / "zones" / "entsoe_full_zones.yaml" + crossborders_file_path: Path = ( + BASE_DIR / "data" / "zones" / "entsoe_full_crossborders.yaml" + ) - # Zone YAML File Paths (required) - zones_file_path: Path - crossborders_file_path: Path + # Storage + timeseries_step_minutes: int = 15 + storage_clients: list[str] = ["clickhouse"] - # Logging Settings + # External services + storage_db_url: str = "http://localhost:8123" + entsoe_url: str = "https://web-api.tp.entsoe.eu/api" + elexon_url: str = "https://data.elexon.co.uk/bmrs/api/v1" + epias_url: str = "https://seffaflik.epias.com.tr/electricity-service/v1" + + # Logging log_level: str = "INFO" - log_handlers: list[str] = ["console"] # Possible values: "console", "file" + log_handlers: list[str] = ["console"] log_file: Path = BASE_DIR / "logs" / "wattnet-api.log" - # Status Check Endpoint Settings - storage_db_url: str = ( - "http://localhost:8123" # URL for the wattnet-storage service (required) - ) - entsoe_url: str = "https://web-api.tp.entsoe.eu/api" # ENTSO-E API (required) - elexon_url: str = "https://data.elexon.co.uk/bmrs/api/v1" # ELEXON API (required) - epias_url: str = ( - "https://seffaflik.epias.com.tr/electricity-service/v1" # EPIAS API (required) - ) - model_config = SettingsConfigDict( - env_file=env_file, + env_prefix="WATTNET_API_", + env_file=["/etc/wattnet/api.env", ".env"], env_file_encoding="utf-8", case_sensitive=False, + extra="ignore", ) -# Singleton instance of Settings settings = Settings() diff --git a/wattnet/api/utils/log.py b/wattnet/api/utils/log.py index a2a3665..981adac 100644 --- a/wattnet/api/utils/log.py +++ b/wattnet/api/utils/log.py @@ -56,38 +56,56 @@ def _get_level(level: str) -> int: return getattr(logging, level.upper(), logging.INFO) -def get(name: str) -> logging.Logger: - """Return a configured logger. +def setup_logging() -> None: + """Configure logging once for all wattnet.* loggers (api + storage). - :param name: Name of the logger (e.g., module or component name) - :type name: str + Must be called once at application startup before any loggers are used. + Configures the ``wattnet`` namespace logger so that both + ``wattnet.api.*`` and ``wattnet.storage.*`` loggers write to the same + handlers (console and/or file) as defined in settings. - :return: Configured logger instance - :rtype: logging.Logger + Idempotent: repeated calls are no-ops. """ - logger = logging.getLogger(name) - logger.handlers.clear() # prevent duplicate handlers + wattnet_logger = logging.getLogger("wattnet") + + if wattnet_logger.handlers: + return - # --- Base config --- fmt = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" level = _get_level(getattr(settings, "log_level", "INFO")) handlers = getattr(settings, "log_handlers", ["console"]) - logger.setLevel(level) - # --- Console handler --- + wattnet_logger.setLevel(level) + # Prevent double-logging: wattnet.* logs are handled here + # not by uvicorn's root handler. + wattnet_logger.propagate = False + if "console" in handlers: console_handler = logging.StreamHandler() console_handler.setLevel(level) console_handler.setFormatter(CustomFormatter(fmt)) - logger.addHandler(console_handler) + wattnet_logger.addHandler(console_handler) - # --- File handler --- if "file" in handlers and getattr(settings, "log_file", None): log_file = settings.log_file log_file.parent.mkdir(parents=True, exist_ok=True) file_handler = logging.FileHandler(log_file, mode="a") file_handler.setLevel(level) file_handler.setFormatter(logging.Formatter(fmt, datefmt="%d-%m-%Y %H:%M:%S")) - logger.addHandler(file_handler) + wattnet_logger.addHandler(file_handler) - return logger + +def get(name: str) -> logging.Logger: + """Return a logger for the given name. + + Calls :func:`setup_logging` on first use so that any module importing + this function before ``app.py`` runs still gets a configured logger. + + :param name: Name of the logger (e.g., module or component name) + :type name: str + + :return: Logger instance + :rtype: logging.Logger + """ + setup_logging() + return logging.getLogger(name) diff --git a/wattnet/api/utils/validation.py b/wattnet/api/utils/validation.py index 5a0322a..ada07e4 100644 --- a/wattnet/api/utils/validation.py +++ b/wattnet/api/utils/validation.py @@ -10,6 +10,7 @@ VALID_FOOTPRINT_TYPES = {"carbon", "water"} VALID_FACTOR_TYPES = {"carbon", "water"} +VALID_IMPACT_TYPES = {"water"} VALID_PRODUCTION_TYPES = { "biomass", "coal", @@ -32,15 +33,15 @@ def validate_location_filters( - zone_id: Optional[str], lat: Optional[float], lon: Optional[float] + zone: Optional[str], lat: Optional[float], lon: Optional[float] ) -> Optional[str]: - """Validate zone_id and lat/lon inputs. + """Validate zone and lat/lon inputs. - Validate that either zone_id or lat/lon are provided (but not both), - and return the resolved zone_id. + Validate that either zone or lat/lon are provided (but not both), + and return the resolved zone. - :param zone_id: wattnet zone code (mutually exclusive with lat/lon) - :type zone_id: Optional[str] + :param zone: wattnet zone code (mutually exclusive with lat/lon) + :type zone: Optional[str] :param lat: Latitude in decimal degrees (DD) :type lat: Optional[float] @@ -49,14 +50,14 @@ def validate_location_filters( :type lon: Optional[float] :raises HTTPException: If validation fails: - - If both zone_id and lat/lon are provided (400) + - If both zone and lat/lon are provided (400) - If only one of lat or lon is provided (400) - If lat/lon are provided but no zone is found for those coordinates (404) """ - if zone_id and (lat is not None or lon is not None): + if zone and (lat is not None or lon is not None): raise HTTPException( status_code=400, - detail="If zone_id is provided, latitude and longitude" + detail="If zone is provided, latitude and longitude " "must NOT be provided.", ) if (lat is not None) != (lon is not None): @@ -65,14 +66,14 @@ def validate_location_filters( detail="Both latitude and longitude must be provided together.", ) if lat is not None and lon is not None: - zone_id_from_coords = geo.get_zone_code(lat, lon) - if not zone_id_from_coords: + zone_from_coords = geo.get_zone_code(lat, lon) + if not zone_from_coords: raise HTTPException( status_code=404, detail="No zone found for the provided coordinates.", ) - return zone_id_from_coords - return zone_id + return zone_from_coords + return zone def validate_footprint_type(footprint_type: Optional[str]) -> None: @@ -83,7 +84,10 @@ def validate_footprint_type(footprint_type: Optional[str]) -> None: :raises HTTPException: If footprint_type is invalid (400) """ - if footprint_type is not None and footprint_type not in VALID_FOOTPRINT_TYPES: + if ( + footprint_type is not None + and footprint_type.lower() not in VALID_FOOTPRINT_TYPES + ): raise HTTPException( status_code=400, detail=f"Invalid footprint_type '{footprint_type}'. " @@ -109,6 +113,24 @@ def validate_factor_type(factor_type: Optional[str]) -> None: ) +def validate_impact_type(impact_type: Optional[str]) -> None: + """Validate that impact_type is one of the allowed values. + + :param impact_type: Type of impact to filter (e.g., water) + :type impact_type: Optional[str] + + :raises HTTPException: If impact_type is invalid (400) + """ + if impact_type is not None and impact_type.lower() not in VALID_IMPACT_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Invalid impact_type '{impact_type}'. " + f"Valid values are: {sorted(VALID_IMPACT_TYPES)}." + ), + ) + + def validate_production_type(production_type: Optional[str]) -> None: """Validate that production_type is one of the allowed values.