From ed5e9e6034b8d3691d8e3d11ddc8c50a6af9c700 Mon Sep 17 00:00:00 2001 From: Stefan Scholz Date: Mon, 10 Aug 2026 18:16:21 +0200 Subject: [PATCH 1/2] add code and tests for first release version --- .env.example | 43 + .github/workflows/release.yml | 145 +++ .github/workflows/validate.yml | 102 ++ .gitignore | 10 +- CONTRIBUTING.md | 218 ++++ README.md | 102 +- docs/{assets => }/guard.svg | 0 pyproject.toml | 114 +- scripts/build_docs.py | 806 ++++++++++++++ src/guard_client/__init__.py | 208 +++- src/guard_client/activities.py | 767 +++++++++++++ src/guard_client/client.py | 777 +++++++++++++- src/guard_client/display.py | 336 ++++++ src/guard_client/env.py | 285 +++++ src/guard_client/exceptions.py | 398 ++++++- src/guard_client/filters.py | 383 +++++++ src/guard_client/local.py | 422 ++++++++ src/guard_client/media.py | 215 ++++ src/guard_client/models.py | 1121 ++++++++++++++++++- src/guard_client/predictors.py | 273 +++++ src/guard_client/probe.py | 608 +++++++++++ src/guard_client/py.typed | 0 src/guard_client/reactions.py | 337 ++++++ src/guard_client/runners.py | 551 ++++++++++ src/guard_client/shares.py | 555 ++++++++++ src/guard_client/spaces.py | 639 +++++++++++ src/guard_client/tasks.py | 275 +++++ src/guard_client/tokens.py | 234 ++++ src/guard_client/transport.py | 645 ++++++++++- tests/__init__.py | 0 tests/conftest.py | 364 +++++++ tests/test_activities.py | 518 +++++++++ tests/test_analyze.py | 276 +++++ tests/test_auth.py | 158 +++ tests/test_contract.py | 128 +++ tests/test_display.py | 414 +++++++ tests/test_docstyle.py | 88 ++ tests/test_env.py | 343 ++++++ tests/test_estimate.py | 226 ++++ tests/test_local.py | 622 +++++++++++ tests/test_media.py | 159 +++ tests/test_predictors.py | 194 ++++ tests/test_probe.py | 267 +++++ tests/test_reactions.py | 426 ++++++++ tests/test_runners.py | 536 ++++++++++ tests/test_shares.py | 526 +++++++++ tests/test_spaces.py | 792 ++++++++++++++ tests/test_tasks.py | 183 ++++ tests/test_tokens.py | 172 +++ tests/test_transport.py | 423 ++++++++ tests/test_version.py | 17 + uv.lock | 1845 ++++++++++++++++++++++++++++++-- 52 files changed, 19141 insertions(+), 105 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/validate.yml create mode 100644 CONTRIBUTING.md rename docs/{assets => }/guard.svg (100%) create mode 100644 scripts/build_docs.py create mode 100644 src/guard_client/activities.py create mode 100644 src/guard_client/display.py create mode 100644 src/guard_client/env.py create mode 100644 src/guard_client/filters.py create mode 100644 src/guard_client/local.py create mode 100644 src/guard_client/media.py create mode 100644 src/guard_client/predictors.py create mode 100644 src/guard_client/probe.py create mode 100644 src/guard_client/py.typed create mode 100644 src/guard_client/reactions.py create mode 100644 src/guard_client/runners.py create mode 100644 src/guard_client/shares.py create mode 100644 src/guard_client/spaces.py create mode 100644 src/guard_client/tasks.py create mode 100644 src/guard_client/tokens.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_activities.py create mode 100644 tests/test_analyze.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_contract.py create mode 100644 tests/test_display.py create mode 100644 tests/test_docstyle.py create mode 100644 tests/test_env.py create mode 100644 tests/test_estimate.py create mode 100644 tests/test_local.py create mode 100644 tests/test_media.py create mode 100644 tests/test_predictors.py create mode 100644 tests/test_probe.py create mode 100644 tests/test_reactions.py create mode 100644 tests/test_runners.py create mode 100644 tests/test_shares.py create mode 100644 tests/test_spaces.py create mode 100644 tests/test_tasks.py create mode 100644 tests/test_tokens.py create mode 100644 tests/test_transport.py create mode 100644 tests/test_version.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5323143 --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# Guard Python client — environment template +# +# Copy this file to `.env` and fill in your values: +# +# cp .env.example .env +# +# `.env` is git-ignored so your secrets never leave your machine. This file is +# committed, so it must only ever contain placeholders — never a real key. +# + +# (REQUIRED) API key +GUARD_API_KEY= + +# (REQUIRED) Default space the media will be processed +GUARD_SPACE_ID= + +# (OPTIONAL) Default organization which owns new spaces, and scopes runner listings +#GUARD_ORGANIZATION_ID= + +# (OPTIONAL) API root +#GUARD_BASE_URL=https://api.elhio.com + +# (OPTIONAL) Engine used, either "cloud" or "local" +#GUARD_ENGINE=cloud + +# (OPTIONAL) Language +#GUARD_LOCALE=en + +# (OPTIONAL) Per-request HTTP timeout, in seconds +#GUARD_TIMEOUT=30.0 + +# (OPTIONAL) Retries on connection errors and 429/5xx responses +#GUARD_MAX_RETRIES=3 + +# (OPTIONAL) Path to the ONNX model for the local engine +#GUARD_LOCAL_MODEL_PATH= + +# (OPTIONAL) Read a different env file instead of `.env` +#GUARD_ENV_FILE=.env.staging + +# (OPTIONAL) Default media file for the smoke test +# Used only by scripts/smoke.py +#GUARD_MEDIA=tests/fixtures/sample.jpg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..33fa3a5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,145 @@ +name: Release + +on: + release: + types: + - published + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + validate: + uses: ./.github/workflows/validate.yml + + build: + needs: validate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: '3.12' + - name: Check the release tag matches the package version + env: + TAG: ${{ github.event.release.tag_name }} + run: | + tag="${TAG#v}" + version="$(uv version --short)" + if [ "$tag" != "$version" ]; then + echo "::error::Release tag '$TAG' does not match the version in pyproject.toml ('$version')." + echo "Bump pyproject.toml, or retag the release." + exit 1 + fi + echo "Releasing guard-client $version" + - name: Build the package + run: uv build + - name: Upload distributions + uses: actions/upload-artifact@v7 + with: + name: release-dist + path: dist/ + retention-days: 7 + + publish-pypi: + needs: build + runs-on: ubuntu-latest + environment: + name: production + permissions: + id-token: write + steps: + - name: Download distributions + uses: actions/download-artifact@v8 + with: + name: release-dist + path: dist + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + attach-assets: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download distributions + uses: actions/download-artifact@v8 + with: + name: release-dist + path: dist + + - name: Upload assets to release + uses: softprops/action-gh-release@v3 + with: + files: | + dist/*.whl + dist/*.tar.gz + + docs: + needs: publish-pypi + runs-on: ubuntu-latest + environment: + name: production + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: '3.12' + - name: Install the docs group + run: uv sync --locked --group docs + - name: Build the documentation + run: uv run python scripts/build_docs.py --strict + - name: Upload the documentation to MinIO + env: + AWS_ACCESS_KEY_ID: ${{ secrets.MINIO_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.MINIO_SECRET_KEY }} + AWS_DEFAULT_REGION: ${{ vars.MINIO_REGION || 'us-east-1' }} + AWS_ENDPOINT_URL_S3: ${{ vars.MINIO_ENDPOINT }} + BUCKET: ${{ vars.DOCS_BUCKET }} + run: | + set -euo pipefail + + # Say which setting is missing, rather than failing later against AWS. + : "${AWS_ENDPOINT_URL_S3:?set the MINIO_ENDPOINT repository variable}" + : "${BUCKET:?set the DOCS_BUCKET repository variable}" + + # MinIO serves path-style addressing. Virtual-host style needs wildcard DNS + # that a self-hosted instance usually does not have, and no environment + # variable controls this, so it goes in the runner's throwaway CLI config. + aws configure set default.s3.addressing_style path + + package="$(uv run python -c "import json; print(json.load(open('docs/docs.json'))['package'])")" + version="$(uv run python -c "import json; print(json.load(open('docs/docs.json'))['version'])")" + key="docs/${package}/${version}.json" + + # `--endpoint-url` is passed explicitly even though AWS_ENDPOINT_URL_S3 is + # normally picked up on its own: that variable needs AWS CLI v2.13+, and a + # runner with anything older would silently upload to the real AWS instead. + + # A released version's documentation is immutable. Re-running a failed + # release job must not quietly rewrite what people have already read. + if aws --endpoint-url "$AWS_ENDPOINT_URL_S3" s3api head-object \ + --bucket "$BUCKET" --key "$key" >/dev/null 2>&1; then + echo "::notice::${key} already exists; leaving it untouched." + else + aws --endpoint-url "$AWS_ENDPOINT_URL_S3" \ + s3 cp docs/docs.json "s3://${BUCKET}/${key}" \ + --content-type application/json \ + --cache-control "public, max-age=31536000, immutable" + echo "Uploaded ${key}" + fi + + aws --endpoint-url "$AWS_ENDPOINT_URL_S3" \ + s3 cp docs/docs.json "s3://${BUCKET}/docs/${package}/latest.json" \ + --content-type application/json \ + --cache-control "no-cache" + echo "Updated docs/${package}/latest.json" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..4cd45ec --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,102 @@ +name: Validate + +on: + push: + branches: + - main + pull_request: + types: + - opened + - synchronize + - reopened + workflow_call: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: '3.12' + - name: Install dependencies + run: uv sync --locked + - name: Lint code + run: uv run ruff check + - name: Check formatting + run: uv run ruff format --check + - name: Type check + run: uv run mypy src/ + - name: Install the docs group + run: uv sync --locked --group docs + - name: Build the documentation + run: uv run python scripts/build_docs.py --strict + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ['3.10', '3.11', '3.12', '3.13'] + include: + - os: macos-latest + python-version: '3.13' + - os: windows-latest + python-version: '3.13' + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: uv sync --locked + - name: Run unit tests + run: uv run pytest -q + + test-contract: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: '3.12' + - name: Install dependencies with the local extra + run: uv sync --locked --extra local + - name: Run the shared contract test + run: uv run pytest tests/test_contract.py -q + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: '3.12' + - name: Build the package + run: uv build + - name: Import the built wheel in a clean environment + run: | + uv run --no-project --isolated --with dist/*.whl \ + python -c "import guard_client; print(guard_client.__version__)" + - name: Upload distributions + uses: actions/upload-artifact@v7 + with: + name: dist-${{ github.sha }} + path: dist/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 5b8786c..2b4167e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ logs *.log +# docs +docs/docs.json + # editor directories and files .vscode/* !.vscode/extensions.json @@ -41,4 +44,9 @@ htmlcov/ test-results/ junit.xml .tox/ -.nox/ \ No newline at end of file +.nox/ + +# build artefacts +dist/ +build/ +*.egg-info/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..76209cc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,218 @@ +# Contributing to Guard + +Thank you for considering contributing to Guard! We welcome contributions from everyone, whether it’s fixing bugs, +adding new features, sharpening the ergonomics, or improving documentation. + +The following is a set of guidelines for contributing to this repository. + +## The Contributor License Agreement (CLA) + +Before we can merge your first Pull Request, you will need to sign our Contributor License Agreement. + +Don't worry, the process is fully automated! When you open your first Pull Request, our CLA bot will automatically +comment on it with instructions. You will simply need to reply to that comment to sign the agreement. You only have to +do this once. + +This repository is licensed under the **Apache-2.0**, and your contributions are published under those terms. The +optional on-device engine it can talk to, `guard-local-detector`, uses the AGPL-3.0 license and lives in its own +repository. This difference is exactly why the local engine is an opt-in extra rather than a standard dependency. + +## Getting Started + +Before you start writing code, make sure your development environment is set up properly. + +0. Fork the repository to your own GitHub account. + +1. Install prerequisites: [uv](https://docs.astral.sh/uv/) and Python 3.10 or newer. You do not need to install Python +yourself — uv will fetch a suitable interpreter. + +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/guard-python.git` + +3. Sync the environment: `uv sync` + +That last command creates a `.venv`, reads `uv.lock`, and installs the runtime and development dependencies exactly as +they were locked. Nothing else is needed — the test suite is fully mocked, so there is no API key to obtain and no +network access at any point. + +Note that two things are deliberately left out of that default environment. The local extra is not installed by default +because it relies on that separate optional engine. If you need it, see [Working on the local engine](#working-on-the-local-engine). +The `docs` group is also excluded since it is only used for building documentation. + +## Branching Strategy + +To keep the repository organized, please use descriptive branch names based on the type of work you are doing: + +* **Features:** `feature/activity-pagination` or `feat/share-expiry-filters` +* **Bug Fixes:** `fix/presigned-upload-retry` +* **Documentation:** `docs/update-readme` + +Always branch off of the `main` branch, and make sure your fork is up to date before starting new work. + +## Development, Linting & Testing + +Please ensure your changes pass all code quality checks and tests before opening a Pull Request. + +### Code Quality & Types + +Run the formatter, linter, and type-checker to ensure your code complies with our standards: + +```bash +# format the code +uv run ruff format + +# check for lint issues +uv run ruff check + +# automatically fix lint issues where possible +uv run ruff check --fix + +# check types (strict mode) +uv run mypy +``` + +Note that `ruff format` rewraps code but never touches comments or docstrings. If you still see an `E501` error, the +long line is inside a comment or docstring and must be split manually. + +### Running Tests + +We use pytest. Please add or update tests whenever you introduce new features or fix bugs. + +```bash +# run the whole suite +uv run pytest + +# run one test with output +uv run pytest tests/test_local.py::test_analyze_returns_unified_result -v +``` + +The suite needs no fixture files: images and video clips are generated in `tests/conftest.py` at run time. If you add a +new media format, add its builder there rather than committing a binary. Similarly, every API response comes from a +builder function in that file, such as `create_response`, `detail_response`, `space_response`, and friends. These are +served through respx against [https://api.test.invalid](https://api.test.invalid). If you add a new endpoint, please +add its builder there instead of committing a fixture. Async tests need no marker since `asyncio_mode` is set to `auto`. + +### Documentation + +Every release publishes a JSON description of the public API built straight from the docstrings. The build runs on every +pull request, so a missing docstring fails the PR instead of the release: + +```bash +uv sync --group docs +uv run python scripts/build_docs.py --strict +``` + +The `scripts/build_docs.py` script is shared with the sibling `guard-local-python` repository. It takes no +package-specific arguments by design and reads everything it needs out of `pyproject.toml`. Please keep any changes to +this script synchronized across both repositories. + +### Working on the Local Engine + +The `local` engine routes through `src/guard_client/local.py` to `guard-local-detector`, the optional on-device engine. +It is not installed by default, so you will need to install the extra when you need it. + +The test suite drives a fake implementing the `LocalEngine` protocol, allowing everything to pass without the engine +installed. However, changes to anything the engine interacts with (like `local.py`, the `LocalEngine` protocol, +`_adapt`, and the exception mapping) should be checked against the real thing: + +```bash +uv sync --extra local +uv run pytest tests/test_contract.py tests/test_local.py +``` + +The `tests/test_contract.py` file is the conformance suite the two packages share. A plain `uv run pytest` skips it +silently when the extra is not installed. Because of this, a green full-suite run does not guarantee you exercised the +local engine. If you explicitly name the file, the test runner will loudly warn you if `guard_local` is missing. When +you are finished, running `uv sync` returns you to the default environment. + +### Smoke Testing against a Live API + +The test suite is fully mocked and never touches the network. To exercise the real lifecycle end-to-end, use the smoke +script. **It runs against production by default and spends real tokens**. Each run creates two activities. You can point +it elsewhere using the `GUARD_BASE_URL` environment variable. + +```bash +export GUARD_API_KEY=... # a token_raw from POST /api/v1/tokens/ +export GUARD_SPACE_ID=... + +uv run python scripts/smoke.py path/to/photo.jpg + +# against a local dev server instead +GUARD_BASE_URL=http://localhost:8000 uv run python scripts/smoke.py path/to/photo.jpg +``` + +Credentials resolve the same way everywhere in this client: explicit arguments, followed by `GUARD_* `environment +variables, and finally a `.env` file. You can simply run `cp .env.example .env` and fill in your details instead of +exporting variables. The `.env` file is git-ignored and must stay that way. Please never put a real key in +`.env.example`. + +If you do not have a `space_id` yet, two more scripts can help: + +```bash +# list the spaces your key can see, with their ids +uv run python scripts/list_spaces.py + +# walk predictors -> tasks -> create a space (--list-only creates nothing) +uv run python scripts/create_space.py --list-only +``` + +## What to Watch Out For + +This package is mostly a thin, well-typed shell around an HTTP API, but a few things in it are load-bearing in ways that +are not obvious from the code. + +**`import guard_client` must never import `guard_local`.** Because the local engine is optional, `local.py` imports it +lazily inside the call. The `tests/test_local.py` file checks this in a subprocess. An in-process assertion would only +report whatever the rest of the test session had already imported. + +**Never make `guard-local-detector` a hard dependency.** It must remain an extra. Aside from the licensing differences +mentioned earlier, making it a hard dependency would create a circular relationship since the engine has no need for +this package. + +**`tests/test_contract.py` is shared byte-identically with `guard-local-python`.** A diff between the two copies means +the contract has changed. It is excluded from `ruff format` and carries its own per-file-ignores entry to preserve this. +Please do not reformat it, and if you modify it, be sure to update both copies. + +**Everything raised must subclass `GuardError`.** hat is the promise our `except` clauses rely on. The engine's own +exceptions do not subclass it, so `local.py` translates each one before it escapes using `_map_local_error`. Anything +that does not come from `guard_local` propagates untouched. This is intentional, as a bug in the engine should surface +exactly as the bug it is. + +**The task labels are a public API.** `_adapt` derives each local result's id from `uuid5(namespace, label)`. Renaming a +label silently changes the ID that callers rely on. These labels also mirror the tasks the cloud API seeds, allowing +users to test locally and then route to the cloud without changing their code. + +**Engine scores must be floats between `0.0` and `1.0`.** The `_score_to_int` function rescales by value rather than by +a declared scale, meaning an integer `1` would be read as full confidence rather than as near-zero. + +**Docstrings have a house style, and it is enforced twice.** The summary goes on the line below the opening quotes and +keeps that shape even when the body is one sentence (with `D200` and `D212` off, and `D213` on). Since Ruff cannot +enforce this expanded form everywhere, `tests/test_docstyle.py` walks the package to verify it. Any public object with +no docstring at all will also fail the strict documentation build. + +## Submitting a Pull Request + +When you are ready to submit your code, open a Pull Request (PR) against the main branch of the original repository. + +Please include the following in your PR description: + +* **The Problem:** What issue does this PR solve? (Link to an existing Issue if applicable). +* **The Solution:** A brief explanation of how you solved it. +* **Testing:** How did you test your changes? Mention which tests were added, and whether you ran the contract suite +with the `[local]` extra. +* **Public API impact:** If anything in a module's `__all__` changed, say so. The generated documentation is published +with every release, and every new public object needs a docstring that survives `--strict`. + +Once submitted, a maintainer will review your code. We may request some changes before merging, but we will always be +respectful and constructive! + +## Reporting Bugs & Requesting Features + +If you aren't writing code but found a bug or have a feature idea, please open an Issue! + +* Provide as much detail as possible, including your Python version, operating system, and `guard_client.__version__`. +The `guard-local-detector` version matters too, but only for `engine="local"` reports. +* For a failed request, include the full traceback, the status code, and the request id. Every `GuardAPIError` carries +one as `.request_id`, taken from the server's `x-request-id` header — it is the single most useful thing in a report, +because it lets us find your exact request in our logs. +* If media was flagged incorrectly, please provide feedback by verifying the result against one of our verification +models (offered through API endpoints) and send a reaction for this particular result. diff --git a/README.md b/README.md index 339c730..8efaad2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@

- Guard Logo
+ Guard Logo
Guard

-

A seamless Python client for integrating visual safety filters into your applications

+

A Python client for seamlessly integrating visual safety filters into your applications

+ Release License: Apache 2.0 PRs Welcome

@@ -12,23 +13,22 @@ ## Features -**☁️ Robust Cloud API:** Seamlessly connects to Elhio Guard's powerful cloud infrastructure for high-accuracy, -multi-layered visual safety filtering. +**🛡️ Multi-Layered Content Moderation:** Automatically detects AI-generated, violent, and explicit content in images and +videos. -**🔌 Optional Local Engine:** Add the `[local]` extension to instantly route local file checks to an on-device, -zero-latency ONNX engine—without changing your code. +**🔄️ Two Engines, One Result Shape:** Use the cloud API for maximum accuracy, or the optional on-device engine to build +and test locally without an API key. Both return the exact same strictly typed models. **⚡ Sync & Async Support:** Natively supports both synchronous operations and `async/await` out of the box, making it -perfect for high-performance frameworks like FastAPI. - -**🛠️ Unified Data Structures:** Whether a request is processed in the cloud API or locally on your hardware, the client -returns the exact same strictly typed models. +a perfect fit for high-performance frameworks like FastAPI. +**🛠️ Comprehensive API Bindings:** Typed bindings for the entire API including spaces, activities, tasks, predictors, +runners, shares, and reactions. We also include token estimation, so you can price a job before you spend on it. ## Installation You can install the client in two ways, depending on whether you want to rely purely on the cloud API or include the -offline fallback engine. +local fallback engine. ### Cloud-Only (Standard) @@ -41,8 +41,8 @@ pip install guard-client ### Cloud + Local (Hybrid) -Installs the client along with the `guard-local-detector` engine. This allows you to process local file paths directly on -your hardware with zero network latency. +Installs the client along with the `guard-local-detector` engine. This allows you to process local file paths directly +on your hardware with zero network latency. *Note: The local engine dependency is licensed under the AGPL-3.0.* ```bash @@ -51,38 +51,74 @@ pip install "guard-client[local]" ## Quick Start -### Cloud Detection +The `analyze()` method runs the entire detection lifecycle for you. It creates an activity, uploads the media, confirms +the upload, polls until processing finishes, and returns the result. + +You will need an **API key** and a space to create the activity in. You can create a new access token in the **Elhio +dashboard** (under Settings > Account Settings > Access Tokens). If you do not know your space ID yet, you can +ask the API for it. The `spaces.list()` method returns every space the key can see. -If you installed via `guard-client[local]`, you do not need to import this package directly. The main client will -automatically detect its presence and route local file checks to this engine. ```python from guard_client import GuardClient -client = GuardClient(api_key="your_api_key_here") +with GuardClient(api_key="your_api_key_here") as client: + # 1. Get an available space + space = client.spaces.list()[0] -# automatically routed to the Elhio Cloud API -result = client.check_media(url="https://example.com/video.mp4") + # 2. Analyze the media (creates activity, uploads, and polls for results) + result = client.analyze("photo.jpg", space_id=space.id) -print(f"Detection Results: {result}") + # 3. Print the detection results + for item in result.results: + print(f"{item.label}: {item.score}/100") + +# AI-Generated: 87/100 +# Violence: 2/100 +# Explicit: 1/100 +``` + +Both values also resolve from the environment, so the common case needs no arguments at all: + +```python +# With GUARD_API_KEY and GUARD_SPACE_ID set (environment or .env) +with GuardClient() as client: + result = client.analyze("photo.jpg") ``` +The `analyze()` method accepts a file path, raw bytes, or an open binary file object. The media type is detected +automatically from the filename or the file's magic bytes. You can pass `media_type=` to skip this automatic detection. + ### Local Detection -If you completed the `guard-client[local]` installation, you can also check images with the local on-device engine. It -returns the exact same data structure as the cloud API, making local testing and air-gapped deployments seamless. +With the `[local]` extra installed, you can pass `engine="local"` to run entirely on-device. This requires no network +calls, no API key, and no space ID. Results use the exact same `DetectionResult` type as the cloud API, so the code +reading them does not need to change. + +Local detection is **opt-in** and never automatic. The engine you get is the engine you explicitly ask for, regardless +of which extras happen to be installed. ```python from guard_client import GuardClient -client = GuardClient() - -# automatically routed to local detection engine -result = client.check_media(file_path="/local/paths/to/video.mp4") - -print(f"Detection Results: {result}") +with GuardClient(engine="local") as client: + # 1. Analyze the media + result = client.analyze("/local/path/to/video.mp4") + + # 2. Print the detection results + for item in result.results: + print(f"{item.label}: {item.score}/100") + +# AI-Generated: 90/100 +# Violence: 2/100 +# Explicit: 1/100 ``` +Two fields easily tell the engines apart. The `result.activity_id` is `None` because a local run creates nothing +server-side, meaning there is nothing to share or react to. The `result.detected` field carries the engine's own +per-category threshold verdict, which the cloud API does not report. Reading it means you are opting into extra detail, +not into a different data shape. + ## Development This project uses [uv](https://docs.astral.sh/uv/) for lightning-fast Python package and environment management. @@ -96,7 +132,7 @@ This project uses [uv](https://docs.astral.sh/uv/) for lightning-fast Python pac 1. Clone the repository: ```bash git clone https://github.com/elhio/guard-python.git - cd guard-local-python + cd guard-python ``` 2. Sync the environment: @@ -111,10 +147,11 @@ This project uses [uv](https://docs.astral.sh/uv/) for lightning-fast Python pac uv run pytest ``` -4. Formatting and linting: +4. Formatting, linting and type checking: ```bash uv run ruff format uv run ruff check + uv run mypy src/ ``` 5. Build for production: @@ -125,10 +162,9 @@ This project uses [uv](https://docs.astral.sh/uv/) for lightning-fast Python pac ## Contributing We welcome contributions! Please note that all contributors must sign our automated CLA. Read more in our -[Contributing Guide](CONTRIBUTING.md). +[Contributing Guide](https://github.com/elhio/guard-python/blob/main/CONTRIBUTING.md). ## License This repository and its corresponding PyPI package are licensed under the Apache v2.0 (Apache-2.0) - see the -[LICENSE](LICENSE) file for details. - +[LICENSE](https://github.com/elhio/guard-python/blob/main/LICENSE) file for details. diff --git a/docs/assets/guard.svg b/docs/guard.svg similarity index 100% rename from docs/assets/guard.svg rename to docs/guard.svg diff --git a/pyproject.toml b/pyproject.toml index f4d0a36..e086cb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,119 @@ [project] name = "guard-client" version = "0.0.1" -description = "A seamless Python client for integrating visual safety filters - including deepfake, violence, and explicit content detection - into your applications" +description = "A Python client for seamlessly integrating visual safety filters into your applications" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" +license = "Apache-2.0" +authors = [{ name = "Elhio" }] +keywords = [ + "content-moderation", + "content-safety", + "ai-generated", + "deepfake", + "violence", + "image-moderation", + "video-moderation", + "trust-and-safety", + "api-client", + "sdk", + "async", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Multimedia :: Video", + "Topic :: Scientific/Engineering :: Image Recognition", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] dependencies = [ "httpx>=0.28.1", + "pydantic>=2.0", + "python-dotenv>=1.0", +] + +[project.optional-dependencies] +local = ["guard-local-detector>=0.0.1"] + +[project.urls] +Homepage = "https://github.com/elhio/guard-python" +Repository = "https://github.com/elhio/guard-python" +Issues = "https://github.com/elhio/guard-python/issues" +Changelog = "https://github.com/elhio/guard-python/releases" + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "respx>=0.21.1", + "ruff>=0.6.0", + "mypy>=1.11.0", + "ipython>=8.0", +] +docs = [ + "griffe>=2.0.0", + "tomli>=2.0.0; python_version < '3.11'", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/guard_client"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["--strict-markers", "--strict-config"] +asyncio_mode = "auto" + +[tool.ruff] +line-length = 88 +src = ["src", "tests"] +target-version = "py310" + +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = 72 +exclude = ["tests/test_contract.py"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "D"] +extend-select = ["D213"] +ignore = [ + "UP006", + "UP007", + "UP035", + "UP045", + "D200", + "D212", ] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["D"] +"tests/test_contract.py" = ["D", "E501", "I001", "SIM105"] +"scripts/**" = ["D101", "D102", "D103", "D107"] + +[tool.mypy] +packages = ["guard_client"] +strict = true + +[[tool.mypy.overrides]] +module = ["guard_local", "guard_local.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["guard_client.display"] +disallow_untyped_calls = false diff --git a/scripts/build_docs.py b/scripts/build_docs.py new file mode 100644 index 0000000..1376f20 --- /dev/null +++ b/scripts/build_docs.py @@ -0,0 +1,806 @@ +#!/usr/bin/env python3 +""" +Builds the JSON documentation that is published with every release. + +Griffe reads the package statically so this needs none of the runtime dependencies and +never imports the code it documents. What it writes is a distilled view of the public +API under a schema this repository owns rather than the internal Griffe model. This +avoids issues with internal details that change between Griffe versions. It processes +the names in the package `__all__` attribute and their public members. + +Example: + ```bash + uv sync --group docs + uv run python scripts/build_docs.py --strict + ``` +""" + +from __future__ import annotations + +import argparse +import json +import logging +import re +import sys +from datetime import datetime, timezone +from importlib.metadata import version as distribution_version +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import griffe +from griffe import ParameterKind + +try: # Python 3.11+ + import tomllib +except ModuleNotFoundError: # pragma: Python 3.10, where tomli is the backport + import tomli as tomllib # type: ignore[no-redef] + +#: Bump whenever a field in the emitted document changes meaning or disappears. +SCHEMA_VERSION = 1 + +#: Attribute values longer than this are truncated; some public constants are large. +MAX_VALUE_CHARS = 2000 + +#: Admonition kinds that read as usage examples rather than asides. +EXAMPLE_KINDS = frozenset({"example", "examples"}) + +ROOT = Path(__file__).resolve().parent.parent + +#: Where the document lands unless `--output` says otherwise. Relative to the repository +#: root, so the command works from any directory. +DEFAULT_OUTPUT = ROOT / "docs" / "docs.json" + + +def read_project() -> Tuple[str, str, str, Path]: + """ + Read the distribution name, version, import name, and source root. + + Returns: + The distribution name, the version, the import name, and the directory to hand + Griffe as a search path. The import name is derived from the wheel package + declaration rather than guessed from the distribution name. + """ + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + project = data["project"] + packages = data["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"] + if len(packages) != 1: + raise SystemExit( + "Expected exactly one entry in [tool.hatch.build.targets.wheel] packages, " + f"found {packages!r}. This script documents a single package." + ) + search_path, _, import_name = packages[0].rpartition("/") + return project["name"], project["version"], import_name, ROOT / search_path + + +def load_package(import_name: str, search_path: Path) -> Any: + """ + Load the package with Griffe statically and without importing it. + + Args: + import_name: The top-level module to load. + search_path: The directory the module lives in. + + Returns: + The loaded module. + """ + # Griffe logs a warning for every alias it cannot resolve, and the standard library + # and third-party annotations produce plenty. They are not actionable here. + logging.getLogger("griffe").setLevel(logging.ERROR) + return griffe.load( + import_name, + search_paths=[search_path], + docstring_parser=griffe.Parser.google, + resolve_aliases=True, + resolve_external=False, + allow_inspection=False, + ) + + +def comment_docstring(obj: Any) -> Optional[str]: + """ + Recover the comment written above a module attribute. + + Griffe only treats a string literal following an assignment as that attribute + docstring. This codebase documents its public constants with the Sphinx-style + comment instead. Without this those would otherwise arrive undocumented. + + Args: + obj: The attribute to look above. + + Returns: + The joined comment text, or `None` when there is no such comment. + """ + lines = getattr(obj, "lines_collection", None) + if lines is None or obj.filepath is None or obj.lineno is None: + return None + try: + source = lines[obj.filepath] + except KeyError: + return None + + collected: List[str] = [] + index = obj.lineno - 2 # `lineno` is 1-based, so this is the line above. + while index >= 0: + line = source[index].strip() + if not line.startswith("#:"): + break + collected.append(line[2:].strip()) + index -= 1 + return " ".join(reversed(collected)) or None + + +def split_summary(text: str) -> Tuple[str, str]: + """ + Split docstring prose into its first paragraph and the rest. + + Args: + text: The joined text of the docstring. + + Returns: + The summary and the remaining description. Either of these may be empty. + """ + stripped = text.strip() + if not stripped: + return "", "" + head, _, tail = stripped.partition("\n\n") + return " ".join(head.split()), unwrap(tail) + + +def unwrap(text: str) -> str: + """ + Drop the line breaks that only exist to keep the source within 88 columns. + + Docstrings are wrapped to the line length the formatter enforces. This is a property + of the source file and not of the prose. A consumer rendering this document wants + paragraphs it can reflow so the soft breaks are removed while the real structure + stays intact. Blank lines still separate paragraphs and any paragraph containing an + indented line is left exactly as written because the indentation carries meaning + that collapsing would destroy. + + Args: + text: The raw description. + + Returns: + The same text with soft wrapping removed. + """ + paragraphs = [] + for paragraph in re.split(r"\n\s*\n", text.strip()): + if re.search(r"^\s+\S", paragraph, re.MULTILINE): + paragraphs.append(paragraph.strip("\n")) + else: + paragraphs.append(" ".join(paragraph.split())) + return "\n\n".join(part for part in paragraphs if part) + + +def join_wrapped(descriptions: List[str]) -> str: + """ + Rejoin a description the Google parser split across several entries. + + A return block with no name is one wrapped paragraph but the parser reads every line + at the same indentation as a separate return value. Joining them back restores the + sentence the author originally wrote. + + Args: + descriptions: The per-entry descriptions in order. + + Returns: + One paragraph. + """ + return re.sub(r"\s+", " ", " ".join(descriptions)).strip() + + +def read_sections(docstring: Any) -> Dict[str, Any]: + """ + Reduce a parsed docstring to the pieces this schema carries. + + Args: + docstring: The Griffe docstring, or `None`. + + Returns: + A mapping with the text, parameters, returns, raises, attributes, examples, and + notes found. Absent sections come back empty rather than missing. + """ + found: Dict[str, Any] = { + "text": "", + "parameters": {}, + "returns": [], + "raises": [], + "attributes": {}, + "examples": [], + "notes": [], + } + if docstring is None: + return found + + texts: List[str] = [] + for section in docstring.parsed: + kind = section.kind.value + if kind == "text": + texts.append(section.value) + elif kind == "parameters": + for item in section.value: + found["parameters"][item.name] = unwrap(item.description) + elif kind == "returns": + found["returns"] = list(section.value) + elif kind == "raises": + for item in section.value: + found["raises"].append( + { + "annotation": str(item.annotation) if item.annotation else None, + "description": unwrap(item.description), + } + ) + elif kind == "attributes": + for item in section.value: + found["attributes"][item.name] = { + "annotation": str(item.annotation) if item.annotation else None, + "description": unwrap(item.description), + } + elif kind == "examples": + found["examples"].append(str(section.value).strip()) + elif kind == "admonition": + admonition = section.value + if admonition.kind in EXAMPLE_KINDS: + found["examples"].append(admonition.contents.strip()) + else: + found["notes"].append( + { + "title": section.title or admonition.kind, + "text": unwrap(admonition.contents), + } + ) + + found["text"] = "\n\n".join(part.strip() for part in texts if part.strip()) + return found + + +def read_returns(func: Any, sections: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Describe what a function returns from its annotation and docstring. + + Args: + func: The Griffe function. + sections: The output of `read_sections` for that function. + + Returns: + The return annotation and description, or `None` when there is neither. + """ + annotation = str(func.returns) if getattr(func, "returns", None) else None + items = sections["returns"] + + if items and all(not item.name for item in items): + # One wrapped paragraph the parser split by line. + description = join_wrapped([item.description for item in items]) + if not annotation and items[0].annotation: + annotation = str(items[0].annotation) + values = None + elif items: + # Genuinely several documented values, so keep them apart. + description = "" + values = [ + { + "name": item.name or None, + "annotation": str(item.annotation) if item.annotation else None, + "description": unwrap(item.description), + } + for item in items + ] + else: + description = "" + values = None + + if not annotation and not description and not values: + return None + + returns: Dict[str, Any] = {"annotation": annotation, "description": description} + if values: + returns["values"] = values + return returns + + +def visible_parameters(func: Any) -> List[Any]: + """ + Drop the bound receiver from the parameters of a method. + + Args: + func: The Griffe function. + + Returns: + Every parameter a caller actually passes. + """ + parameters = list(func.parameters) + if parameters and parameters[0].name in {"self", "cls"}: + return parameters[1:] + return parameters + + +def render_signature(func: Any) -> str: + """ + Render a call signature the way it would be written in source. + + Args: + func: The Griffe function. + + Returns: + The signature including annotations, defaults, and the return type. + """ + parts: List[str] = [] + star_written = False + parameters = visible_parameters(func) + + for index, param in enumerate(parameters): + if param.kind is ParameterKind.var_positional: + star_written = True + parts.append(f"*{param.name}") + continue + if param.kind is ParameterKind.var_keyword: + parts.append(f"**{param.name}") + continue + if param.kind is ParameterKind.keyword_only and not star_written: + parts.append("*") + star_written = True + + rendered = param.name + if param.annotation is not None: + rendered += f": {param.annotation}" + if param.default is not None: + rendered += ( + f" = {param.default}" if param.annotation else f"={param.default}" + ) + parts.append(rendered) + + following = parameters[index + 1 :] + if param.kind is ParameterKind.positional_only and not any( + other.kind is ParameterKind.positional_only for other in following + ): + parts.append("/") + + returns = f" -> {func.returns}" if getattr(func, "returns", None) else "" + return f"({', '.join(parts)}){returns}" + + +def document_parameters(func: Any, described: Dict[str, str]) -> List[Dict[str, Any]]: + """ + Pair each parameter in the signature with its documented description. + + Args: + func: The Griffe function. + described: Descriptions keyed by parameter name. + + Returns: + One entry per parameter in signature order. The annotation, default, and kind + come from the signature. This ensures a docstring that repeats the type cannot + contradict the code. + """ + documented: List[Dict[str, Any]] = [] + for param in visible_parameters(func): + documented.append( + { + "name": param.name, + "annotation": str(param.annotation) if param.annotation else None, + "default": str(param.default) if param.default is not None else None, + "kind": param.kind.value, + "description": described.get(param.name, ""), + } + ) + return documented + + +def truncate(value: str) -> Tuple[str, bool]: + """ + Cap an attribute value so one large constant cannot dominate the document. + + Args: + value: The rendered value. + + Returns: + The value and whether it was shortened. + """ + if len(value) <= MAX_VALUE_CHARS: + return value, False + return value[:MAX_VALUE_CHARS] + " ...", True + + +def source_of(obj: Any) -> Dict[str, Any]: + """ + Locate an object in the repository. + + Args: + obj: The Griffe object. + + Returns: + The defining module, its path relative to the repository root, and the line + number. The absolute path is deliberately omitted because it would publish the + layout of whichever machine built the release. + """ + return { + "module": obj.module.path if obj.parent else obj.path, + "relative_filepath": str(obj.relative_filepath) if obj.filepath else None, + "lineno": obj.lineno, + } + + +def document_function(func: Any, path: str) -> Dict[str, Any]: + """ + Describe a function or method. + + Args: + func: The Griffe function. + path: The public dotted path callers reach it by. + + Returns: + The documented function. + """ + sections = read_sections(func.docstring) + summary, description = split_summary(sections["text"]) + + documented: Dict[str, Any] = { + "name": func.name, + "path": path, + "canonical_path": func.canonical_path, + "kind": "function", + "summary": summary, + "description": description, + "signature": render_signature(func), + "parameters": document_parameters(func, sections["parameters"]), + "returns": read_returns(func, sections), + "raises": sections["raises"], + "examples": sections["examples"], + "notes": sections["notes"], + "source": source_of(func), + } + if func.decorators: + documented["decorators"] = [str(item.value) for item in func.decorators] + return documented + + +def document_attribute(attribute: Any, path: str) -> Dict[str, Any]: + """ + Describe a module-level or class-level constant. + + Args: + attribute: The Griffe attribute. + path: The public dotted path callers reach it by. + + Returns: + The documented attribute including its value. This is frequently the + documentation people actually want for a constant. + """ + text = ( + attribute.docstring.value + if attribute.docstring + else comment_docstring(attribute) + ) + summary, description = split_summary(text or "") + + documented: Dict[str, Any] = { + "name": attribute.name, + "path": path, + "canonical_path": attribute.canonical_path, + "kind": "attribute", + "summary": summary, + "description": description, + "annotation": str(attribute.annotation) if attribute.annotation else None, + "source": source_of(attribute), + } + if attribute.value is not None: + value, truncated = truncate(str(attribute.value)) + documented["value"] = value + documented["truncated"] = truncated + return documented + + +def inherited_attributes(cls: Any, sections: Dict[str, Any]) -> Dict[str, Any]: + """ + Collect the documented attributes of a class and everything it inherits from. + + For example, `SignalMatch` adds two fields to `Signal` and documents only those two. + The other six are described on the base class. Reading the class alone would report + them as undocumented. + + Args: + cls: The Griffe class. + sections: The output of `read_sections` for that class. + + Returns: + Attribute entries keyed by name with the attributes of the class winning over + the base classes. + """ + collected: Dict[str, Any] = {} + try: + ancestors = list(reversed(cls.mro())) + except Exception: # noqa: BLE001 - an unresolved base is not worth failing over + ancestors = [] + for ancestor in ancestors: + collected.update(read_sections(ancestor.docstring)["attributes"]) + collected.update(sections["attributes"]) + return collected + + +def document_class(cls: Any, path: str) -> Dict[str, Any]: + """ + Describe a class and fold its constructor into the class itself. + + Args: + cls: The Griffe class. + path: The public dotted path callers reach it by. + + Returns: + The documented class. The constructor is not emitted as a member because its + parameters are the class parameters which is where a reader looks for them. For + a dataclass whose constructor is synthesised and carries no docstring, the + parameter descriptions are taken from the class attributes section instead. + + Raises: + KeyError: Never raised directly. Documented members are looked + up defensively. + """ + sections = read_sections(cls.docstring) + summary, description = split_summary(sections["text"]) + bases = [str(base) for base in cls.bases] + is_enum = any(base.split(".")[-1].endswith("Enum") for base in bases) + + documented: Dict[str, Any] = { + "name": cls.name, + "path": path, + "canonical_path": cls.canonical_path, + "kind": "class", + "summary": summary, + "description": description, + "bases": bases, + "is_enum": is_enum, + "labels": sorted(cls.labels), + "signature": "()", + "parameters": [], + "raises": sections["raises"], + "examples": sections["examples"], + "notes": sections["notes"], + "attributes": [], + "members": [], + "source": source_of(cls), + } + + constructor = cls.members.get("__init__") + if constructor is not None and not constructor.is_alias: + described = read_sections(constructor.docstring) + # A synthesised dataclass constructor has no docstring of its own, so the + # field descriptions live in the class's `Attributes:` section instead — and + # for a subclass, the inherited fields are documented on the base. + parameter_docs = described["parameters"] or { + name: entry["description"] + for name, entry in inherited_attributes(cls, sections).items() + } + documented["signature"] = render_signature(constructor).removesuffix(" -> None") + documented["parameters"] = document_parameters(constructor, parameter_docs) + documented["raises"] = described["raises"] or documented["raises"] + if described["text"]: + constructor_summary, constructor_description = split_summary( + described["text"] + ) + documented["constructor"] = { + "summary": constructor_summary, + "description": constructor_description, + } + + for name, entry in inherited_attributes(cls, sections).items(): + documented["attributes"].append( + { + "name": name, + "annotation": entry["annotation"], + "description": entry["description"], + } + ) + + for name, member in cls.members.items(): + if name.startswith("_"): + continue + target = resolve(member) + if target is None: + continue + member_path = f"{path}.{name}" + if target.kind is griffe.Kind.FUNCTION: + documented["members"].append(document_function(target, member_path)) + elif target.kind is griffe.Kind.ATTRIBUTE and ( + is_enum or target.docstring or comment_docstring(target) + ): + # Undocumented class attributes are almost always dataclass fields, which + # the `Attributes:` section above already covers. An enum is the exception: + # its members are the whole point of it, and they carry their value rather + # than a docstring. + documented["members"].append(document_attribute(target, member_path)) + + return documented + + +def resolve(obj: Any) -> Optional[Any]: + """ + Follow an alias to the object it points at. + + Args: + obj: A Griffe object or alias. + + Returns: + The object itself, or `None` when the alias cannot be resolved. This happens for + anything re-exported from outside the package. + """ + if not obj.is_alias: + return obj + try: + return obj.final_target + except Exception: # noqa: BLE001 - griffe raises several unrelated alias errors + return None + + +def document(obj: Any, path: str) -> Optional[Dict[str, Any]]: + """ + Describe any exported object. + + Args: + obj: The Griffe object. + path: The public dotted path callers reach it by. + + Returns: + The documented object, or `None` for a kind this schema does not carry. + """ + if obj.kind is griffe.Kind.CLASS: + return document_class(obj, path) + if obj.kind is griffe.Kind.FUNCTION: + return document_function(obj, path) + if obj.kind is griffe.Kind.ATTRIBUTE: + return document_attribute(obj, path) + return None + + +def check(documented: Dict[str, Any], problems: List[str], gaps: List[str]) -> None: + """ + Complain about anything public that is not documented. + + This operates at two levels because they are not equally serious. A public object + with no docstring at all is a hole in the reference and fails the strict check. A + missing argument description is reported but never fatal. Methods on the async + client deliberately carry a summary and cross-reference their synchronous twin + instead of repeating nine argument descriptions. + + Args: + documented: One documented object. + problems: The list to append hard failures to. + gaps: The list to append advisory findings to. + """ + path = documented["path"] + if not documented["summary"]: + problems.append(f"{path} has no docstring") + + for parameter in documented.get("parameters", []): + if parameter["kind"] in {"variadic positional", "variadic keyword"}: + continue + if not parameter["description"]: + gaps.append(f"{path} does not document its `{parameter['name']}` argument") + + returns = documented.get("returns") + if ( + returns + and returns["annotation"] not in (None, "None") + and not returns["description"] + and "values" not in returns + ): + gaps.append(f"{path} does not document what it returns") + + for member in documented.get("members", []): + if documented.get("is_enum") and member["kind"] == "attribute": + # An enum member documents itself: `Engine.LOCAL = "local"` says it all. + continue + check(member, problems, gaps) + + +def build() -> Tuple[Dict[str, Any], List[str], List[str]]: + """ + Build the entire document. + + Returns: + The document, the hard problems that fail the strict check, and the advisory + gaps that are only ever reported. + """ + name, package_version, import_name, search_path = read_project() + package = load_package(import_name, search_path) + + exports = package.exports or sorted(package.members) + problems: List[str] = [] + gaps: List[str] = [] + objects: List[Dict[str, Any]] = [] + + for export in exports: + export = str(export) + if export.startswith("__"): + # `__version__` and friends are metadata, not API. + continue + member = package.members.get(export) + if member is None: + problems.append( + f"{import_name}.__all__ names {export!r}, which does not exist" + ) + continue + target = resolve(member) + if target is None: + problems.append(f"{import_name}.{export} could not be resolved by griffe") + continue + documented = document(target, f"{import_name}.{export}") + if documented is None: + problems.append( + f"{import_name}.{export} is a {target.kind.value}, " + "which this schema does not carry" + ) + continue + objects.append(documented) + check(documented, problems, gaps) + + summary, _ = split_summary(package.docstring.value if package.docstring else "") + document_out = { + "schema_version": SCHEMA_VERSION, + "package": name, + "import_name": import_name, + "version": package_version, + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "generator": {"tool": "griffe", "version": distribution_version("griffe")}, + "summary": summary, + "objects": objects, + } + return document_out, problems, gaps + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"where to write the JSON document (default: {DEFAULT_OUTPUT})", + ) + parser.add_argument( + "--strict", + action="store_true", + help="exit non-zero when a public object has no docstring at all", + ) + parser.add_argument( + "--show-gaps", + action="store_true", + help="also list undocumented arguments and return values, which never fail", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + documented, problems, gaps = build() + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(documented, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + # The default is an absolute path so the command works from any directory; report it + # relative to the repository so the log line stays readable. + written = args.output.resolve() + if written.is_relative_to(ROOT): + written = written.relative_to(ROOT) + print( + f"Documented {len(documented['objects'])} public objects of " + f"{documented['package']} {documented['version']} -> {written}" + ) + + if gaps: + print(f"{len(gaps)} arguments or return values are undocumented.") + if args.show_gaps: + for gap in gaps: + print(f" - {gap}") + + if problems: + stream = sys.stderr if args.strict else sys.stdout + print(f"\n{len(problems)} public objects have no docstring:", file=stream) + for problem in problems: + print(f" - {problem}", file=stream) + if args.strict: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/guard_client/__init__.py b/src/guard_client/__init__.py index 8d45211..36d5739 100644 --- a/src/guard_client/__init__.py +++ b/src/guard_client/__init__.py @@ -1 +1,207 @@ -# TODO: exposes public classes (e.g., from .client import GuardClient) \ No newline at end of file +""" +A Python client for Elhio's Guard visual safety detection API. + +Quick start: + +```python +from guard_client import GuardClient + +with GuardClient(api_key="...", space_id="...") as client: + result = client.analyze("photo.jpg") + for item in result.results: + print(item.label, item.score) +``` +""" + +from importlib.metadata import PackageNotFoundError, version + +from .activities import Activities, AsyncActivities +from .client import AsyncGuardClient, GuardClient +from .display import BROWSER_RENDERABLE, load_media, save, show +from .env import DEFAULT_ENV_FILE, ENV_FILE_VAR, EnvSource, read_env_file +from .exceptions import ( + ActivityFailedError, + GuardAPIError, + GuardAuthError, + GuardConflictError, + GuardConnectionError, + GuardError, + GuardLocalEngineError, + GuardLocalModelError, + GuardMediaDecodeError, + GuardNotFoundError, + GuardPaymentRequiredError, + GuardRateLimitError, + GuardServerError, + GuardTimeoutError, + GuardUploadError, + GuardValidationError, + LocalEngineNotInstalledError, + UnsupportedMediaTypeError, +) +from .filters import MAX_HISTORY, MAX_LIMIT +from .media import SUPPORTED_MEDIA_TYPES, MediaSource +from .models import ( + FILTERABLE_RUNNER_STATUSES, + Activity, + ActivityCreateResponse, + ActivityDetail, + ActivityOrder, + ActivityPage, + ActivityResult, + ActivityResultItem, + ActivityStatus, + ActivityStatusResponse, + DetectionMatch, + DetectionResult, + Engine, + MediaCategory, + MediaType, + Page, + Predictor, + PredictorOrder, + PredictorPage, + PredictorStatus, + PresignedUploadData, + Reaction, + Runner, + RunnerOrder, + RunnerPage, + RunnerStatus, + Share, + ShareOrder, + SharePage, + ShareStatus, + SortOrder, + Space, + SpaceDetail, + SpaceOrder, + SpacePage, + SpaceStatus, + SpaceThresholds, + Task, + TaskOrder, + TaskPage, + TaskStatus, +) +from .predictors import AsyncPredictors, Predictors +from .probe import MediaInfo, probe_media +from .reactions import AsyncReactions, Reactions +from .runners import AsyncRunners, Runners +from .shares import AsyncShares, Shares +from .spaces import AsyncSpaces, Spaces +from .tasks import AsyncTasks, Tasks +from .tokens import ( + MAX_LONG_SIDE, + RESOLUTION_TIERS, + TokenEstimate, + estimate_tokens, + frames_for, + tier_for, +) +from .transport import DEFAULT_BASE_URL + +try: + __version__ = version("guard-client") +except PackageNotFoundError: # pragma: running from an uninstalled checkout + __version__ = "0.0.0+unknown" + +__all__ = [ + "DEFAULT_BASE_URL", + "DEFAULT_ENV_FILE", + "ENV_FILE_VAR", + "FILTERABLE_RUNNER_STATUSES", + "BROWSER_RENDERABLE", + "MAX_HISTORY", + "MAX_LIMIT", + "MAX_LONG_SIDE", + "RESOLUTION_TIERS", + "SUPPORTED_MEDIA_TYPES", + "GuardClient", + "AsyncGuardClient", + "Activities", + "AsyncActivities", + "Spaces", + "AsyncSpaces", + "Predictors", + "AsyncPredictors", + "Tasks", + "AsyncTasks", + "Runners", + "AsyncRunners", + "Reactions", + "AsyncReactions", + "Shares", + "AsyncShares", + "EnvSource", + "read_env_file", + "load_media", + "save", + "show", + "estimate_tokens", + "frames_for", + "probe_media", + "tier_for", + "Activity", + "ActivityCreateResponse", + "ActivityDetail", + "ActivityOrder", + "ActivityPage", + "ActivityResult", + "ActivityResultItem", + "ActivityStatus", + "ActivityStatusResponse", + "DetectionMatch", + "DetectionResult", + "Engine", + "MediaCategory", + "MediaSource", + "MediaType", + "Page", + "Predictor", + "PredictorOrder", + "PredictorPage", + "PredictorStatus", + "PresignedUploadData", + "Reaction", + "Runner", + "RunnerOrder", + "RunnerPage", + "RunnerStatus", + "Share", + "ShareOrder", + "SharePage", + "ShareStatus", + "SortOrder", + "MediaInfo", + "Space", + "SpaceDetail", + "SpaceOrder", + "SpacePage", + "SpaceStatus", + "SpaceThresholds", + "Task", + "TokenEstimate", + "TaskOrder", + "TaskPage", + "TaskStatus", + "ActivityFailedError", + "GuardAPIError", + "GuardAuthError", + "GuardConflictError", + "GuardConnectionError", + "GuardError", + "GuardLocalEngineError", + "GuardLocalModelError", + "GuardMediaDecodeError", + "GuardNotFoundError", + "GuardPaymentRequiredError", + "GuardRateLimitError", + "GuardServerError", + "GuardTimeoutError", + "GuardUploadError", + "GuardValidationError", + "LocalEngineNotInstalledError", + "UnsupportedMediaTypeError", + "__version__", +] diff --git a/src/guard_client/activities.py b/src/guard_client/activities.py new file mode 100644 index 0000000..5f74e48 --- /dev/null +++ b/src/guard_client/activities.py @@ -0,0 +1,767 @@ +""" +Low-level bindings for the activity endpoints. + +`Activities` and `AsyncActivities` provide synchronous and asynchronous interfaces for +the API. Both share `_ActivitiesBase` for request building to guarantee identical +behavior. +""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Union +from uuid import UUID + +from .exceptions import ActivityFailedError, GuardError, GuardTimeoutError +from .filters import ( + MAX_LIMIT, + DateLike, + IdLike, + add_datetime, + add_ids, + add_sort, + add_statuses, + reject_too_old, + validate_pagination, +) +from .media import MediaSource, resolve_media +from .models import ( + Activity, + ActivityCreateResponse, + ActivityDetail, + ActivityOrder, + ActivityPage, + ActivityStatus, + ActivityStatusResponse, + MediaType, + PresignedUploadData, + SortOrder, +) +from .transport import AsyncTransport, SyncTransport + +__all__ = ["Activities", "AsyncActivities", "IdLike"] + +#: The base URL path for activity endpoints. +_BASE = "/api/v1/activities/" + +#: Default seconds to wait between status checks when polling an activity. +DEFAULT_POLL_INTERVAL = 2 + +#: Default maximum seconds to wait for an activity to complete before raising a timeout +#: error. +DEFAULT_POLL_TIMEOUT = 90.0 + + +class _ActivitiesBase: + """ + Handles request and response shaping without performing network I/O. + + All logic that does not touch the network lives here. This ensures that the + synchronous and asynchronous clients cannot drift in how they build or validate + requests. + """ + + def __init__(self, default_space_id: Optional[IdLike] = None) -> None: + """ + Remember the space to fall back on. + + Args: + default_space_id: Used when a call omits `space_id`. It can be set once on + the client instead of on every call. + """ + self._default_space_id = default_space_id + + def _resolve_space_id(self, space_id: Optional[IdLike]) -> str: + """ + Pick the space for this call. + + Args: + space_id: The per-call value, or `None` to use the client default. + + Returns: + The space id as a string. + + Raises: + GuardError: Neither source supplied a space id. The message names every way + to set it, including the `GUARD_SPACE_ID` environment variable. + """ + effective = space_id if space_id is not None else self._default_space_id + if effective is None: + raise GuardError( + "space_id is required. Pass it to this call or set it on the client: " + "GuardClient(api_key=..., space_id=...)" + ) + return str(effective) + + def _create_payload( + self, + *, + media_type: Union[MediaType, str], + media_size: int, + space_id: Optional[IdLike], + user_id: Optional[IdLike], + account_id: Optional[IdLike], + ) -> Dict[str, Any]: + """ + Build the body for creating an activity. + + Returns: + The request body, omitting every owner id that was not supplied. + + Raises: + GuardError: No space id is available from the call or the client default. + """ + resolved_type = ( + media_type.value if isinstance(media_type, MediaType) else media_type + ) + payload: Dict[str, Any] = { + "space_id": self._resolve_space_id(space_id), + "media_type": resolved_type, + "media_size": media_size, + } + for key, value in ( + ("user_id", user_id), + ("account_id", account_id), + ): + if value is not None: + payload[key] = str(value) + return payload + + @staticmethod + def _list_params( + *, + user_id: Optional[IdLike], + organization_id: Optional[IdLike], + space_id: Optional[IdLike], + start_date: Optional[DateLike], + end_date: Optional[DateLike], + statuses: Optional[Sequence[Union[ActivityStatus, str]]], + sort_by: Optional[Union[ActivityOrder, str]], + sort_order: Optional[Union[SortOrder, str]], + skip: int, + limit: int, + ) -> Dict[str, Any]: + """ + Build the query parameters for a list request. + + Returns: + The query dict, omitting every unset filter. + + Raises: + GuardError: A filter value is invalid. + """ + validate_pagination(skip, limit) + reject_too_old(start_date, field="start_date") + + params: Dict[str, Any] = {"skip": skip, "limit": limit} + add_ids( + params, + user_id=user_id, + organization_id=organization_id, + space_id=space_id, + ) + add_datetime(params, "start_date", start_date) + add_datetime(params, "end_date", end_date) + add_statuses(params, statuses, ActivityStatus) + add_sort(params, sort_by, sort_order, ActivityOrder) + return params + + @staticmethod + def _check_terminal( + status: ActivityStatusResponse, activity_id: IdLike + ) -> Optional[ActivityStatusResponse]: + """ + Return the activity status if valid, raising on failure. + """ + if status.status is ActivityStatus.COMPLETED: + return status + if status.status in (ActivityStatus.FAILED, ActivityStatus.CANCELED): + raise ActivityFailedError( + f"Activity {activity_id} {status.status.value}", + status=status.status.value, + activity_id=_as_uuid(activity_id), + ) + return None + + +def _as_uuid(value: IdLike) -> Optional[UUID]: + """ + Return as UUID. + + Args: + value: A UUID or its string form. + + Returns: + The UUID, or `None` when the value is not a valid UUID. An exception attribute + is not worth failing a request over. + """ + if isinstance(value, UUID): + return value + try: + return UUID(str(value)) + except (ValueError, AttributeError): + return None + + +class Activities(_ActivitiesBase): + """ + Synchronous activity endpoints. + + This is reached through the client rather than constructed directly, and it shares + the client's connection pool. + """ + + def __init__( + self, transport: SyncTransport, *, default_space_id: Optional[IdLike] = None + ) -> None: + """ + Bind this resource to a transport with an optional default space id. + + Args: + transport: The client's transport, whose connection pool is shared. + default_space_id: Used when a call omits it. The id can be set once on the + client instead of on every call. + """ + super().__init__(default_space_id) + self._transport = transport + + def create( + self, + *, + media_type: Union[MediaType, str], + media_size: int, + space_id: Optional[IdLike] = None, + user_id: Optional[IdLike] = None, + account_id: Optional[IdLike] = None, + ) -> ActivityCreateResponse: + """ + Create an activity and get back its presigned upload target. + + Args: + media_type: MIME type of the media you will upload. + media_size: Its size in bytes. + space_id: Overrides the client default space id. + user_id: Owning user, for a user-owned activity. + account_id: Owning service account. + + Returns: + The created activity, including the one-time `upload_data`. + + Raises: + GuardError: No space id is available. + GuardAPIError: The API rejected the request. + + Note: + This request is safe to replay and is the only POST the client retries. + A duplicate create leaves an unused activity rather than performing an + action twice. + """ + payload = self._create_payload( + media_type=media_type, + media_size=media_size, + space_id=space_id, + user_id=user_id, + account_id=account_id, + ) + data = self._transport.request("POST", _BASE, json=payload, retry=True) + return ActivityCreateResponse.model_validate(data) + + def upload( + self, + upload_data: PresignedUploadData, + source: MediaSource, + *, + media_type: Optional[Union[MediaType, str]] = None, + filename: Optional[str] = None, + ) -> None: + """ + Upload the media bytes to the presigned storage target. + + Args: + upload_data: The target returned from `create`. + source: A path, raw `bytes`, or an open binary file. + media_type: Skips MIME detection when you already know the type. + filename: Name for the multipart file part. + + Raises: + GuardUploadError: Storage rejected the upload. + UnsupportedMediaTypeError: The media type is not one the API accepts. + """ + data, _, name = resolve_media(source, media_type=media_type, filename=filename) + self._transport.upload(upload_data.url, upload_data.fields, name, data) + + def confirm(self, activity_id: IdLike) -> Activity: + """ + Confirm the upload, which moves the activity into processing. + + Args: + activity_id: The activity whose media has been uploaded. + + Returns: + The activity, which is now processing. + + Raises: + GuardNotFoundError: Unknown activity, or not owned by you. + """ + data = self._transport.request("POST", f"{_BASE}{activity_id}/confirm") + return Activity.model_validate(data) + + def get_status(self, activity_id: IdLike) -> ActivityStatusResponse: + """ + Read just enough of an activity to know its current state. + + This response is deliberately smaller than `get` so polling an activity does + not repeatedly transfer its results. + + Args: + activity_id: The activity to check. + + Returns: + The id and current status of the activity. + + Raises: + GuardNotFoundError: Unknown activity, or not owned by you. + """ + data = self._transport.request("GET", f"{_BASE}{activity_id}/status") + return ActivityStatusResponse.model_validate(data) + + def get(self, activity_id: IdLike) -> ActivityDetail: + """ + Read an activity in full, including its results. + + Args: + activity_id: The activity to fetch. + + Returns: + The activity. `result_payload` is `None` until processing finishes, and + `payed_tokens` carries the actual cost. + + Raises: + GuardNotFoundError: Unknown activity, or not owned by you. + """ + data = self._transport.request("GET", f"{_BASE}{activity_id}") + return ActivityDetail.model_validate(data) + + def list( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + space_id: Optional[IdLike] = None, + start_date: Optional[DateLike] = None, + end_date: Optional[DateLike] = None, + statuses: Optional[Sequence[Union[ActivityStatus, str]]] = None, + sort_by: Optional[Union[ActivityOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> ActivityPage: + """ + List activities matching the given filters. + + Args: + user_id: Only this user's activities. This may be combined with + `organization_id`, and the API will apply both. + organization_id: Only this organization's activities. + space_id: Only activities in this space. + start_date: A `datetime`, `date`, or an ISO-8601 string. The API keeps only + one year of history. Anything older is rejected locally before the + request is sent. Omitting it defaults to exactly one year ago. A naive + datetime is interpreted as UTC to match the server. + end_date: Accepts the same types as start_date. Defaults to now. + statuses: Keep only activities with these statuses. + sort_by: Valid options include `"created_at"`. Server default: `created_at`. + sort_order: `"asc"` or `"desc"`. Server default for activities: `desc`. + skip: Offset, 0 or greater. + limit: Page size, 1-100. + + Returns: + An `ActivityPage`. You can iterate it like a list, or read `.count` for the + total matching the filter across all pages. + """ + params = self._list_params( + user_id=user_id, + organization_id=organization_id, + space_id=space_id, + start_date=start_date, + end_date=end_date, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = self._transport.request("GET", _BASE, params=params) + return ActivityPage.model_validate(data) + + def iter_all( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + space_id: Optional[IdLike] = None, + start_date: Optional[DateLike] = None, + end_date: Optional[DateLike] = None, + statuses: Optional[Sequence[Union[ActivityStatus, str]]] = None, + sort_by: Optional[Union[ActivityOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> Iterator[Activity]: + """ + Yield every matching activity by fetching pages as needed. + + Yields: + Each matching activity, starting with the oldest page first. + + Note: + Pages are fetched lazily. Breaking out of the loop early stops the requests + rather than paying for the whole set. + """ + skip = 0 + while True: + page = self.list( + user_id=user_id, + organization_id=organization_id, + space_id=space_id, + start_date=start_date, + end_date=end_date, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + yield from page.data + + skip += len(page.data) + # A short page means the end; the length check also guarantees termination + # if `count` is stale or wrong. + if not page.data or len(page.data) < page_size or skip >= page.count: + return + + def wait_until_done( + self, + activity_id: IdLike, + *, + interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_POLL_TIMEOUT, + ) -> ActivityStatusResponse: + """ + Poll until the activity completes. + + Args: + activity_id: The activity to check. + interval: Seconds to wait between polling requests. + timeout: Maximum seconds to wait before raising an error. + + Raises: + ActivityFailedError: The activity ended as `failed` or `canceled`. + GuardTimeoutError: The specified timeout seconds elapsed without reaching a + terminal status. + """ + deadline = time.monotonic() + timeout + while True: + status = self.get_status(activity_id) + done = self._check_terminal(status, activity_id) + if done is not None: + return done + if time.monotonic() + interval >= deadline: + raise GuardTimeoutError( + f"Activity {activity_id} did not complete within {timeout}s " + f"(last status: {status.status.value})", + activity_id=_as_uuid(activity_id), + ) + time.sleep(interval) + + +class AsyncActivities(_ActivitiesBase): + """ + Asynchronous activity endpoints. + + This is reached through the client rather than constructed directly, and it shares + the client's connection pool. It mirrors `Activities` method for method. Review the + synchronous methods for argument details. + """ + + def __init__( + self, transport: AsyncTransport, *, default_space_id: Optional[IdLike] = None + ) -> None: + """ + Bind this resource to a transport with an optional default space id. + + Args: + transport: The client's transport, whose connection pool is shared. + default_space_id: Used when a call omits it. The id can be set once on the + client instead of on every call. + """ + super().__init__(default_space_id) + self._transport = transport + + async def create( + self, + *, + media_type: Union[MediaType, str], + media_size: int, + space_id: Optional[IdLike] = None, + user_id: Optional[IdLike] = None, + account_id: Optional[IdLike] = None, + ) -> ActivityCreateResponse: + """ + Create an activity and get back its presigned upload target. + + Args: + media_type: MIME type of the media you will upload. + media_size: Its size in bytes. + space_id: Overrides the client default space id. + user_id: Owning user, for a user-owned activity. + account_id: Owning service account. + + Returns: + The created activity, including the one-time `upload_data`. + + Raises: + GuardError: No space id is available. + GuardAPIError: The API rejected the request. + + Note: + This request is safe to replay and is the only POST the client retries. A + duplicate create leaves an unused activity rather than performing an action + twice. + """ + payload = self._create_payload( + media_type=media_type, + media_size=media_size, + space_id=space_id, + user_id=user_id, + account_id=account_id, + ) + data = await self._transport.request("POST", _BASE, json=payload, retry=True) + return ActivityCreateResponse.model_validate(data) + + async def upload( + self, + upload_data: PresignedUploadData, + source: MediaSource, + *, + media_type: Optional[Union[MediaType, str]] = None, + filename: Optional[str] = None, + ) -> None: + """ + Upload the media bytes to the presigned storage target. + + Args: + upload_data: The target returned from `create`. + source: A path, raw `bytes`, or an open binary file. + media_type: Skips MIME detection when you already know the type. + filename: Name for the multipart file part. + + Raises: + GuardUploadError: Storage rejected the upload. + UnsupportedMediaTypeError: The media type is not one the API accepts. + """ + data, _, name = resolve_media(source, media_type=media_type, filename=filename) + await self._transport.upload(upload_data.url, upload_data.fields, name, data) + + async def confirm(self, activity_id: IdLike) -> Activity: + """ + Confirm the upload, which moves the activity into processing. + + Args: + activity_id: The activity whose media has been uploaded. + + Returns: + The activity, which is now processing. + + Raises: + GuardNotFoundError: Unknown activity, or not owned by you. + """ + data = await self._transport.request("POST", f"{_BASE}{activity_id}/confirm") + return Activity.model_validate(data) + + async def get_status(self, activity_id: IdLike) -> ActivityStatusResponse: + """ + Read just enough of an activity to know its current state. + + This response is deliberately smaller than `get` so polling an activity does not + repeatedly transfer its results. + + Args: + activity_id: The activity to check. + + Returns: + The id and current status of the activity. + + Raises: + GuardNotFoundError: Unknown activity, or not owned by you. + """ + data = await self._transport.request("GET", f"{_BASE}{activity_id}/status") + return ActivityStatusResponse.model_validate(data) + + async def get(self, activity_id: IdLike) -> ActivityDetail: + """ + Read an activity in full, including its results. + + Args: + activity_id: The activity to fetch. + + Returns: + The activity. `result_payload` is `None` until processing finishes, and + `payed_tokens` carries the actual cost. + + Raises: + GuardNotFoundError: Unknown activity, or not owned by you. + """ + data = await self._transport.request("GET", f"{_BASE}{activity_id}") + return ActivityDetail.model_validate(data) + + async def list( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + space_id: Optional[IdLike] = None, + start_date: Optional[DateLike] = None, + end_date: Optional[DateLike] = None, + statuses: Optional[Sequence[Union[ActivityStatus, str]]] = None, + sort_by: Optional[Union[ActivityOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> ActivityPage: + """ + List activities matching the given filters. + + Args: + user_id: Only this user's activities. This may be combined with + `organization_id`, and the API will apply both. + organization_id: Only this organization's activities. + space_id: Only activities in this space. + start_date: A `datetime`, `date`, or an ISO-8601 string. The API keeps only + one year of history. Anything older is rejected locally before the + request is sent. Omitting it defaults to exactly one year ago. A naive + datetime is interpreted as UTC to match the server. + end_date: Accepts the same types as start_date. Defaults to now. + statuses: Keep only activities with these statuses. + sort_by: Valid options include `"created_at"`. Server default: `created_at`. + sort_order: `"asc"` or `"desc"`. Server default for activities: `desc`. + skip: Offset, 0 or greater. + limit: Page size, 1-100. + + Returns: + An `ActivityPage`. You can iterate it like a list, or read `.count` for the + total matching the filter across all pages. + """ + params = self._list_params( + user_id=user_id, + organization_id=organization_id, + space_id=space_id, + start_date=start_date, + end_date=end_date, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = await self._transport.request("GET", _BASE, params=params) + return ActivityPage.model_validate(data) + + async def iter_all( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + space_id: Optional[IdLike] = None, + start_date: Optional[DateLike] = None, + end_date: Optional[DateLike] = None, + statuses: Optional[Sequence[Union[ActivityStatus, str]]] = None, + sort_by: Optional[Union[ActivityOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> AsyncIterator[Activity]: + """ + Yield every matching activity by fetching pages as needed. + + Args: + user_id: Only this user's activities. This may be combined with + `organization_id`, and the API will apply both. + organization_id: Only this organization's activities. + space_id: Only activities in this space. + start_date: A `datetime`, `date`, or an ISO-8601 string. The API keeps only + one year of history. Anything older is rejected locally before the + request is sent. Omitting it defaults to exactly one year ago. A naive + datetime is interpreted as UTC to match the server. + end_date: Accepts the same types as start_date. Defaults to now. + statuses: Keep only activities with these statuses. + sort_by: Valid options include `"created_at"`. Server default: `created_at`. + sort_order: `"asc"` or `"desc"`. Server default for activities: `desc`. + page_size: Page size, 1-100. + + Yields: + Each matching activity, starting with the oldest page first. + + Note: + Pages are fetched lazily. Breaking out of the loop early stops the requests + rather than paying for the whole set. + """ + skip = 0 + while True: + page = await self.list( + user_id=user_id, + organization_id=organization_id, + space_id=space_id, + start_date=start_date, + end_date=end_date, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + for activity in page.data: + yield activity + + skip += len(page.data) + if not page.data or len(page.data) < page_size or skip >= page.count: + return + + async def wait_until_done( + self, + activity_id: IdLike, + *, + interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_POLL_TIMEOUT, + ) -> ActivityStatusResponse: + """ + Poll until the activity completes. + + Args: + activity_id: The activity to check. + interval: Seconds to wait between polling requests. + timeout: Maximum seconds to wait before raising an error. + + Returns: + The terminal status. Check `Activities.wait_until_done` for reference. + + Raises: + ActivityFailedError: The activity ended as `failed` or `canceled`. + GuardTimeoutError: The deadline passed while it was still running. + + Note: + This method sleeps with `asyncio.sleep`, ensuring that waiting never blocks + the event loop. + """ + deadline = time.monotonic() + timeout + while True: + status = await self.get_status(activity_id) + done = self._check_terminal(status, activity_id) + if done is not None: + return done + if time.monotonic() + interval >= deadline: + raise GuardTimeoutError( + f"Activity {activity_id} did not complete within {timeout}s " + f"(last status: {status.status.value})", + activity_id=_as_uuid(activity_id), + ) + await asyncio.sleep(interval) diff --git a/src/guard_client/client.py b/src/guard_client/client.py index 2e958ff..04ec37f 100644 --- a/src/guard_client/client.py +++ b/src/guard_client/client.py @@ -1 +1,776 @@ -# TODO: main GuardClient class (user-facing API methods) \ No newline at end of file +""" +The user-facing Guard clients. + +`GuardClient` and `AsyncGuardClient` expose the same surface in blocking and +`async`/`await` form. Both share `_ClientBase` for client construction to guarantee +identical behavior. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Union + +import httpx + +from .activities import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_POLL_TIMEOUT, + Activities, + AsyncActivities, +) +from .env import DEFAULT_ENV_FILE, EnvFile, EnvSource +from .exceptions import GuardError +from .filters import IdLike +from .local import LocalRunner +from .media import MediaSource, resolve_media +from .models import DetectionResult, Engine, MediaType, coerce_enum +from .predictors import AsyncPredictors, Predictors +from .probe import probe_media +from .reactions import AsyncReactions, Reactions +from .runners import AsyncRunners, Runners +from .shares import AsyncShares, Shares +from .spaces import AsyncSpaces, Spaces +from .tasks import AsyncTasks, Tasks +from .tokens import TokenEstimate, estimate_tokens, frames_for +from .transport import DEFAULT_BASE_URL, AsyncTransport, SyncTransport, TransportConfig + +__all__ = ["AsyncGuardClient", "GuardClient"] + +#: A type alias representing a valid engine configuration. +EngineLike = Union[Engine, str] + +# Effective defaults. They live here rather than in the signatures because every +# env-backed parameter defaults to None, meaning "not provided" — that is what lets an +# explicit argument be told apart from an omitted one. + +#: The default engine to use for analysis when none is specified. +DEFAULT_ENGINE = Engine.CLOUD + +#: The default locale for API responses. +DEFAULT_LOCALE = "en" + +#: The default timeout in seconds for API requests. +DEFAULT_TIMEOUT = 30.0 + +#: The default maximum number of retries for failed requests. +DEFAULT_MAX_RETRIES = 3 + + +def _coerce_engine(engine: EngineLike) -> Engine: + """ + Validate an engine name. + + Args: + engine: An `Engine` member or its string value. + + Returns: + The matching member. + + Raises: + GuardError: The value is neither `"cloud"` nor `"local"`. + """ + return coerce_enum(engine, Engine, field="engine") + + +class _ClientBase: + """ + Configuration and validation shared by both clients. + + This resolves every setting from arguments, the environment, and `.env` exactly + once. This ensures the synchronous and asynchronous clients cannot disagree about + what they were told. + """ + + def __init__( + self, + api_key: Optional[str] = None, + *, + space_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + base_url: Optional[str] = None, + engine: Optional[EngineLike] = None, + locale: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + local_model_path: Optional[str] = None, + env_file: EnvFile = DEFAULT_ENV_FILE, + ) -> None: + """ + Resolve every setting from arguments, the environment, and `.env`. + + Args: + api_key: `GUARD_API_KEY`. Required for every cloud request. A client built + without one can only run `engine="local"` detection. Any API call it + makes raises an error rather than going out unauthenticated. + space_id: `GUARD_SPACE_ID`. Required for cloud calls. + organization_id: `GUARD_ORGANIZATION_ID`. Default owner for new spaces, and + the organization runners are scoped to. + base_url: `GUARD_BASE_URL`. Defaults to `https://api.elhio.com`. + engine: `GUARD_ENGINE`. `"cloud"` (default) or `"local"`. + locale: `GUARD_LOCALE`. Defaults to `"en"`. + timeout: `GUARD_TIMEOUT`. Per-request HTTP timeout. Defaults to 30.0. + max_retries: `GUARD_MAX_RETRIES`. Defaults to 3. + local_model_path: `GUARD_LOCAL_MODEL_PATH`. Only used by the local engine. + env_file: Which env file to read. `None` disables `.env` entirely. + + Raises: + GuardError: Cloud mode without an API key, or an unknown engine. + + Note: + Every argument defaults to `None` meaning it is not provided. This is what + lets an explicit value be told apart from an omitted one. The effective + defaults are listed above. + """ + env = EnvSource(env_file) + + resolved_engine = ( + engine if engine is not None else env.get_optional("GUARD_ENGINE") + ) + self._engine = _coerce_engine( + DEFAULT_ENGINE if resolved_engine is None else resolved_engine + ) + self._space_id: Optional[IdLike] = ( + space_id if space_id is not None else env.get_optional("GUARD_SPACE_ID") + ) + self._organization_id: Optional[IdLike] = ( + organization_id + if organization_id is not None + else env.get_optional("GUARD_ORGANIZATION_ID") + ) + self._config = TransportConfig( + api_key=env.get_optional("GUARD_API_KEY", api_key), + base_url=env.get_str("GUARD_BASE_URL", base_url, DEFAULT_BASE_URL), + locale=env.get_str("GUARD_LOCALE", locale, DEFAULT_LOCALE), + timeout=env.get_float("GUARD_TIMEOUT", timeout, DEFAULT_TIMEOUT), + max_retries=env.get_int( + "GUARD_MAX_RETRIES", max_retries, DEFAULT_MAX_RETRIES + ), + ) + if self._engine is Engine.CLOUD and not self._config.api_key: + raise GuardError( + "An API key is required for cloud detection. Pass api_key=..., set the " + "GUARD_API_KEY environment variable, or put it in a .env file (see " + ".env.example). To run fully on-device, use " + 'GuardClient(engine="local").' + ) + self._local = LocalRunner( + model_path=env.get_optional("GUARD_LOCAL_MODEL_PATH", local_model_path) + ) + + @property + def engine(self) -> Engine: + """ + The default engine for `analyze`. + + Returns: + Whichever engine was configured. Individual calls may override it. + """ + return self._engine + + @property + def base_url(self) -> str: + """ + The API root this client talks to. + + Returns: + The resolved base URL, without a trailing slash. + """ + return self._config.base_url + + def _effective_engine(self, engine: Optional[EngineLike]) -> Engine: + """ + Pick the engine for one call. + + Args: + engine: A per-call override, or `None` to use the client default. + + Returns: + The engine to use. + + Raises: + GuardError: The override is not a known engine. + """ + return self._engine if engine is None else _coerce_engine(engine) + + @staticmethod + def _resolve_estimate_inputs( + *, + source: Optional[MediaSource], + frames: Optional[int], + width: Optional[int], + height: Optional[int], + duration_seconds: Optional[float], + media_type: Optional[MediaType] = None, + filename: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Fill in whatever was not supplied by probing `source`. + + Probing is skipped entirely when every value is already known. This ensures the + pure calculation never touches the filesystem. + """ + needs_probe = ( + width is None + or height is None + or (frames is None and duration_seconds is None) + ) + + if needs_probe: + if source is None: + raise GuardError( + "Nothing to estimate from. Pass a file as the first argument, or " + "supply frames=, width= and height= directly." + ) + info = probe_media(source, media_type=media_type, filename=filename) + width = info.width if width is None else width + height = info.height if height is None else height + if duration_seconds is None: + duration_seconds = info.duration_seconds + + if duration_seconds is None: + duration_seconds = 0.0 + # Always derive from the *effective* duration, so an explicit duration_seconds + # still wins over whatever the probe found. + if frames is None: + frames = frames_for(duration_seconds) + + if width is None or height is None: + raise GuardError( + "Dimensions are required. Pass width= and height=, or a file to probe." + ) + return { + "frames": frames, + "width": width, + "height": height, + "duration_seconds": duration_seconds, + } + + +class GuardClient(_ClientBase): + """ + Synchronous Guard client. + + Every argument below falls back to the matching `GUARD_*` environment variable, + then to a `.env` file, then to the default shown. See `guard_client.env`. + + Args: + api_key: `GUARD_API_KEY`. Required for every cloud request. A client built + without one can only run `engine="local"` detection. Any API call it makes + raises an error rather than going out unauthenticated. + space_id: `GUARD_SPACE_ID`. Required for cloud calls. + organization_id: `GUARD_ORGANIZATION_ID`. Default owner for `Spaces.create` and + the organization `Runners.list` scopes to. + base_url: `GUARD_BASE_URL`. Defaults to `https://api.elhio.com`. + engine: `GUARD_ENGINE`. `"cloud"` (default) or `"local"`. + locale: `GUARD_LOCALE`. Defaults to `"en"`. + timeout: `GUARD_TIMEOUT`. Per-request HTTP timeout, defaults to `30.0`. + max_retries: `GUARD_MAX_RETRIES`. Defaults to `3`. + local_model_path: `GUARD_LOCAL_MODEL_PATH`. Only used by the local engine. + env_file: Which env file to read. `None` disables `.env` entirely. + `GUARD_ENV_FILE` overrides the default of `.env`. + http_client: Supply your own `httpx.Client` to control pooling or proxies. + + Example: + ```python + with GuardClient(api_key=KEY, space_id=SPACE) as client: + result = client.analyze("photo.jpg") + print(result.max_score) + ``` + """ + + def __init__( + self, + api_key: Optional[str] = None, + *, + space_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + base_url: Optional[str] = None, + engine: Optional[EngineLike] = None, + locale: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + local_model_path: Optional[str] = None, + env_file: EnvFile = DEFAULT_ENV_FILE, + http_client: Optional[httpx.Client] = None, + ) -> None: + """ + Build a synchronous client. + + Args: + api_key: `GUARD_API_KEY`. Required for every cloud request. A client built + without one can only run `engine="local"` detection. Any API call it + makes raises an error rather than going out unauthenticated. + space_id: `GUARD_SPACE_ID`. Required for cloud calls. + organization_id: `GUARD_ORGANIZATION_ID`. Default owner for new spaces, + and the organization runners are scoped to. + base_url: `GUARD_BASE_URL`. Defaults to `https://api.elhio.com`. + engine: `GUARD_ENGINE`. `"cloud"` (default) or `"local"`. + locale: `GUARD_LOCALE`. Defaults to `"en"`. + timeout: `GUARD_TIMEOUT`. Per-request HTTP timeout, defaults to `30.0`. + max_retries: `GUARD_MAX_RETRIES`. Defaults to `3`. + local_model_path: `GUARD_LOCAL_MODEL_PATH`. Only used by the local engine. + env_file: Which env file to read. `None` disables `.env` entirely. + http_client: Supply your own `httpx.Client` to control pooling or proxies. + When given, closing this client leaves it open. + + Raises: + GuardError: Cloud mode without an API key, or an unknown engine. + + Note: + Every argument defaults to `None` meaning it is not provided. This is what + lets an explicit value be told apart from an omitted one. The effective + defaults are listed above. + """ + super().__init__( + api_key, + space_id=space_id, + organization_id=organization_id, + base_url=base_url, + engine=engine, + locale=locale, + timeout=timeout, + max_retries=max_retries, + local_model_path=local_model_path, + env_file=env_file, + ) + self._transport = SyncTransport(self._config, http_client=http_client) + # The resolved ids, not the raw arguments. They may have come from the + # environment. + self.activities = Activities(self._transport, default_space_id=self._space_id) + self.spaces = Spaces( + self._transport, default_organization_id=self._organization_id + ) + self.runners = Runners( + self._transport, default_organization_id=self._organization_id + ) + self.predictors = Predictors(self._transport) + self.tasks = Tasks(self._transport) + self.reactions = Reactions(self._transport) + self.shares = Shares(self._transport) + + def analyze( + self, + source: MediaSource, + *, + space_id: Optional[IdLike] = None, + engine: Optional[EngineLike] = None, + media_type: Optional[Union[MediaType, str]] = None, + filename: Optional[str] = None, + user_id: Optional[IdLike] = None, + account_id: Optional[IdLike] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_POLL_TIMEOUT, + ) -> DetectionResult: + """ + Run the full detection lifecycle for one piece of media. + + In cloud mode this creates an activity, uploads the bytes, confirms the upload, + polls until processing finishes, and returns the result. In local mode it runs + on-device and makes no network calls. + + Args: + source: A file path, raw `bytes`, or an open binary file object. + space_id: Overrides the client-level default for this call. + engine: Overrides the client-level default engine for this call. + media_type: Skips MIME detection when you already know the type. + filename: Used for detection and for naming the uploaded file. + user_id: Owning user, for a user-owned activity. + account_id: Owning service account. + poll_interval: Seconds between status polls. + timeout: Seconds to wait for processing before giving up. + + Returns: + A `DetectionResult`, identical in shape for either engine. + + Raises: + ActivityFailedError: Processing ended as `failed` or `canceled`. + GuardTimeoutError: Processing did not finish within `timeout`. + UnsupportedMediaTypeError: The media type is not accepted. + LocalEngineNotInstalledError: Local was requested without the extra. + GuardLocalEngineError: The local engine failed. Subclasses distinguish a + model that would not load from media that would not decode. + + Example: + ```python + with GuardClient() as client: + result = client.analyze("photo.jpg") + for item in result.results: + print(item.label, item.score) + ``` + """ + data, resolved_type, name = resolve_media( + source, media_type=media_type, filename=filename + ) + + if self._effective_engine(engine) is Engine.LOCAL: + return self._local.analyze(data, media_type=resolved_type, filename=name) + + activity = self.activities.create( + media_type=resolved_type, + media_size=len(data), + space_id=space_id, + user_id=user_id, + account_id=account_id, + ) + self.activities.upload( + activity.upload_data, data, media_type=resolved_type, filename=name + ) + self.activities.confirm(activity.id) + self.activities.wait_until_done( + activity.id, interval=poll_interval, timeout=timeout + ) + + detail = self.activities.get(activity.id) + results = detail.result_payload.results if detail.result_payload else [] + return DetectionResult( + engine=Engine.CLOUD, results=results, activity_id=activity.id + ) + + def estimate_tokens( + self, + source: Optional[MediaSource] = None, + *, + space_id: Optional[IdLike] = None, + multiplier: Optional[int] = None, + frames: Optional[int] = None, + width: Optional[int] = None, + height: Optional[int] = None, + duration_seconds: Optional[float] = None, + media_type: Optional[MediaType] = None, + filename: Optional[str] = None, + ) -> TokenEstimate: + """ + Project what analyzing this media will cost before creating an activity. + + `tokens = frames x resolution_cost x multiplier`. Anything not given is read + from `source`. Anything given overrides the probe, so you can correct a single + value without supplying the rest. + + Args: + source: A path, `bytes` or file object to probe. Omit it and supply + `frames`, `width` and `height` for a pure calculation. + space_id: Whose `predictor_multiplier` to use. Defaults to the client space. + multiplier: Skips the space lookup entirely, making this fully offline. + frames: Overrides the frame count derived from the duration. + width: Overrides the probed width, in pixels. + height: Overrides the probed height, in pixels. + duration_seconds: Overrides the probed duration, and hence the frames. + media_type: Skips MIME detection when you already know the type. + filename: Helps identify raw bytes, and names the file in errors. + + Returns: + A `TokenEstimate` carrying the breakdown as well as the total. + + Raises: + GuardError: Values are missing or out of range, or the resolution exceeds + the top tier. + + Note: + This is an estimate. The API currently reserves only the minimum possible + cost when an activity is created, and `payed_tokens` on the finished + activity is authoritative. Expect the two to differ. + """ + resolved = self._resolve_estimate_inputs( + source=source, + frames=frames, + width=width, + height=height, + duration_seconds=duration_seconds, + media_type=media_type, + filename=filename, + ) + if multiplier is None: + multiplier = self._fetch_multiplier(space_id) + return estimate_tokens(multiplier=multiplier, **resolved) + + def _fetch_multiplier(self, space_id: Optional[IdLike]) -> int: + """ + Read `predictor_multiplier` off the space. + + This is the only network call `estimate_tokens` makes, and it is skipped + entirely when `multiplier` is supplied. + + Args: + space_id: The space to read, or `None` to use the client default. + + Returns: + The token multiplier of the space. + + Raises: + GuardError: No space id is available, or the space reports no multiplier. + GuardNotFoundError: Unknown space, or you cannot see it. + """ + effective = space_id if space_id is not None else self._space_id + if effective is None: + raise GuardError( + "A multiplier is required to estimate tokens. Pass multiplier=... to " + "skip the lookup, or space_id=... (or set one on the client) so it can " + "be read from the space." + ) + detail = self.spaces.get(effective) + if detail.predictor_multiplier is None: + raise GuardError( + f"Space {effective} reports no predictor_multiplier. " + f"Pass multiplier=... explicitly." + ) + return detail.predictor_multiplier + + def close(self) -> None: + """ + Release the underlying HTTP connection pool. + + This is unnecessary when the client is used as a context manager, which closes + it for you. + """ + self._transport.close() + + def __enter__(self) -> GuardClient: + """ + Enter a context manager. + + Returns: + This client, unchanged. + """ + return self + + def __exit__(self, *exc_info: Any) -> None: + """ + Leave a context manager, closing the connection pool. + + Args: + *exc_info: Exception details. These are ignored because closing happens + either way. + """ + self.close() + + +class AsyncGuardClient(_ClientBase): + """ + Asynchronous Guard client. Mirrors `GuardClient`. + + Takes the same arguments and the same `GUARD_*` and `.env` fallbacks. See + `GuardClient` for the full list. + + Example: + ```python + async with AsyncGuardClient(api_key=KEY, space_id=SPACE) as client: + result = await client.analyze("photo.jpg") + ``` + """ + + def __init__( + self, + api_key: Optional[str] = None, + *, + space_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + base_url: Optional[str] = None, + engine: Optional[EngineLike] = None, + locale: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + local_model_path: Optional[str] = None, + env_file: EnvFile = DEFAULT_ENV_FILE, + http_client: Optional[httpx.AsyncClient] = None, + ) -> None: + """ + Build an asynchronous client. + + Args: + api_key: `GUARD_API_KEY`. Required for every cloud request. A client + built without one can only run `engine="local"` detection. Any API + call it makes raises an error rather than going out unauthenticated. + space_id: `GUARD_SPACE_ID`. Required for cloud calls. + organization_id: `GUARD_ORGANIZATION_ID`. Default owner for new spaces, + and the organization runners are scoped to. + base_url: `GUARD_BASE_URL`. Defaults to `https://api.elhio.com`. + engine: `GUARD_ENGINE`. `"cloud"` (default) or `"local"`. + locale: `GUARD_LOCALE`. Defaults to `"en"`. + timeout: `GUARD_TIMEOUT`. Per-request HTTP timeout, defaults to `30.0`. + max_retries: `GUARD_MAX_RETRIES`. Defaults to `3`. + local_model_path: `GUARD_LOCAL_MODEL_PATH`. Only used by the local engine. + env_file: Which env file to read. `None` disables `.env` entirely. + http_client: Supply your own `httpx.AsyncClient` to control pooling or + proxies. When given, closing this client leaves it open. + + Raises: + GuardError: Cloud mode without an API key, or an unknown engine. + + Note: + Every argument defaults to `None` meaning it is not provided. This is what + lets an explicit value be told apart from an omitted one. The effective + defaults are listed above. + """ + super().__init__( + api_key, + space_id=space_id, + organization_id=organization_id, + base_url=base_url, + engine=engine, + locale=locale, + timeout=timeout, + max_retries=max_retries, + local_model_path=local_model_path, + env_file=env_file, + ) + self._transport = AsyncTransport(self._config, http_client=http_client) + # The resolved ids, not the raw arguments: they may have come from the + # environment. + self.activities = AsyncActivities( + self._transport, default_space_id=self._space_id + ) + self.spaces = AsyncSpaces( + self._transport, default_organization_id=self._organization_id + ) + self.runners = AsyncRunners( + self._transport, default_organization_id=self._organization_id + ) + self.predictors = AsyncPredictors(self._transport) + self.tasks = AsyncTasks(self._transport) + self.reactions = AsyncReactions(self._transport) + self.shares = AsyncShares(self._transport) + + async def analyze( + self, + source: MediaSource, + *, + space_id: Optional[IdLike] = None, + engine: Optional[EngineLike] = None, + media_type: Optional[Union[MediaType, str]] = None, + filename: Optional[str] = None, + user_id: Optional[IdLike] = None, + account_id: Optional[IdLike] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_POLL_TIMEOUT, + ) -> DetectionResult: + """ + Run the full detection lifecycle for one piece of media. + + Returns: + A `DetectionResult`. See `GuardClient.analyze` for every argument and the + errors it can raise. + """ + data, resolved_type, name = resolve_media( + source, media_type=media_type, filename=filename + ) + + if self._effective_engine(engine) is Engine.LOCAL: + return await self._local.analyze_async( + data, media_type=resolved_type, filename=name + ) + + activity = await self.activities.create( + media_type=resolved_type, + media_size=len(data), + space_id=space_id, + user_id=user_id, + account_id=account_id, + ) + await self.activities.upload( + activity.upload_data, data, media_type=resolved_type, filename=name + ) + await self.activities.confirm(activity.id) + await self.activities.wait_until_done( + activity.id, interval=poll_interval, timeout=timeout + ) + + detail = await self.activities.get(activity.id) + results = detail.result_payload.results if detail.result_payload else [] + return DetectionResult( + engine=Engine.CLOUD, results=results, activity_id=activity.id + ) + + async def estimate_tokens( + self, + source: Optional[MediaSource] = None, + *, + space_id: Optional[IdLike] = None, + multiplier: Optional[int] = None, + frames: Optional[int] = None, + width: Optional[int] = None, + height: Optional[int] = None, + duration_seconds: Optional[float] = None, + media_type: Optional[MediaType] = None, + filename: Optional[str] = None, + ) -> TokenEstimate: + """ + Project what analyzing this media will cost before creating an activity. + + Returns: + A `TokenEstimate`. See `GuardClient.estimate_tokens` for every argument. + + Warning: + This is an estimate, not a quote. `payed_tokens` on the finished activity + is authoritative. + """ + resolved = self._resolve_estimate_inputs( + source=source, + frames=frames, + width=width, + height=height, + duration_seconds=duration_seconds, + media_type=media_type, + filename=filename, + ) + if multiplier is None: + multiplier = await self._fetch_multiplier(space_id) + return estimate_tokens(multiplier=multiplier, **resolved) + + async def _fetch_multiplier(self, space_id: Optional[IdLike]) -> int: + """ + Read `predictor_multiplier` off the space. + + This is the only network call `estimate_tokens` makes, and it is skipped + entirely when `multiplier` is supplied. + + Args: + space_id: The space to read, or `None` to use the client default. + + Returns: + The token multiplier of the space. + + Raises: + GuardError: No space id is available, or the space reports no multiplier. + GuardNotFoundError: Unknown space, or you cannot see it. + """ + effective = space_id if space_id is not None else self._space_id + if effective is None: + raise GuardError( + "A multiplier is required to estimate tokens. Pass multiplier=... to " + "skip the lookup, or space_id=... (or set one on the client) so it can " + "be read from the space." + ) + detail = await self.spaces.get(effective) + if detail.predictor_multiplier is None: + raise GuardError( + f"Space {effective} reports no predictor_multiplier. " + f"Pass multiplier=... explicitly." + ) + return detail.predictor_multiplier + + async def aclose(self) -> None: + """ + Release the underlying HTTP connection pool. + + This is unnecessary when the client is used as an async context manager. + """ + await self._transport.aclose() + + async def __aenter__(self) -> AsyncGuardClient: + """ + Enter an async context manager. + + Returns: + This client, unchanged. + """ + return self + + async def __aexit__(self, *exc_info: Any) -> None: + """ + Leave an async context manager, closing the connection pool. + + Args: + *exc_info: Exception details. These are ignored because closing happens + either way. + """ + await self.aclose() diff --git a/src/guard_client/display.py b/src/guard_client/display.py new file mode 100644 index 0000000..e85ce5b --- /dev/null +++ b/src/guard_client/display.py @@ -0,0 +1,336 @@ +""" +Viewing and saving media for notebooks and scripts alike. + +The `show()` function renders inline in Jupyter and falls back to the operating system +viewer anywhere else. `save()` writes bytes to disk and never opens anything. + +Both accept whatever source you already have: a local file, raw bytes, a URL, or a +result object carrying a `media_url`. Neither requires a configured client. Result media +lives at plain, unauthenticated URLs, meaning no credentials are involved. +""" + +from __future__ import annotations + +import mimetypes +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any, List, Optional, Tuple, Union + +import httpx + +from .exceptions import GuardError +from .media import MediaSource, resolve_media +from .models import MediaType + +__all__ = ["load_media", "save", "show"] + +#: Types a browser will actually render. HEIC is unsupported in Chrome and Firefox, +#: and `video/quicktime` is poorly supported. Embedding either produces a silently +#: broken element, so they take the system-viewer path instead. +BROWSER_RENDERABLE = frozenset( + { + MediaType.JPEG, + MediaType.PNG, + MediaType.WEBP, + MediaType.GIF, + MediaType.MP4, + MediaType.WEBM, + } +) + +#: Video media types used to determine inline display behavior. +_VIDEO_TYPES = frozenset({MediaType.MP4, MediaType.WEBM, MediaType.QUICKTIME}) + +#: Extensions to fall back on when a media type has no `mimetypes` entry. +_EXTENSIONS = { + MediaType.JPEG: ".jpg", + MediaType.PNG: ".png", + MediaType.WEBP: ".webp", + MediaType.GIF: ".gif", + MediaType.HEIC: ".heic", + MediaType.MP4: ".mp4", + MediaType.WEBM: ".webm", + MediaType.QUICKTIME: ".mov", +} + +#: A type alias for the sources that can be displayed or saved. +DisplaySource = Union[MediaSource, Any] + + +def _is_url(value: Any) -> bool: + """ + Decide whether a source is an HTTP or HTTPS URL rather than a path. + + Args: + value: Any accepted source. + + Returns: + `True` for a string starting with `http://` or `https://`. + """ + return isinstance(value, str) and value.startswith(("http://", "https://")) + + +def _fetch(url: str) -> bytes: + """ + Download media from a plain URL. + + This is deliberately a bare request. Result media lives at unauthenticated URLs, + and attaching the client API key would hand it to a third-party host. + """ + try: + response = httpx.get(url, follow_redirects=True, timeout=30.0) + except httpx.HTTPError as exc: + raise GuardError(f"Could not download {url}: {exc}") from exc + + if not response.is_success: + raise GuardError( + f"Could not download {url}: {response.status_code} {response.reason_phrase}" + ) + return response.content + + +def _media_url_of(source: Any) -> Optional[str]: + """ + Extract the `media_url` of a result item, share, or anything else exposing one. + + This uses duck typing rather than isinstance checks so new models work without + requiring changes here. + """ + return ( + getattr(source, "media_url", None) + if not isinstance(source, (str, bytes)) + else None + ) + + +def load_media( + source: DisplaySource, + *, + media_type: Optional[MediaType] = None, + filename: Optional[str] = None, +) -> Tuple[bytes, MediaType, str]: + """ + Resolve any supported source to `(data, media_type, filename)`. + + Args: + source: A path, raw `bytes`, an open binary file, an HTTP or HTTPS URL, + or an object with a `media_url` such as an `ActivityResultItem` or `Share`. + media_type: Skips detection when you already know the type. + filename: Used for detection and for naming a saved file. + + Raises: + GuardError: A result object carries no media, or a URL could not be fetched. + UnsupportedMediaTypeError: The media type is not one the API accepts. + """ + if not _is_url(source): + url = _media_url_of(source) + if url is not None: + source = url + elif hasattr(source, "media_url"): + # attribute exists but is None, meaning the result has no image at all + raise GuardError( + f"{type(source).__name__} has no media_url, so there is nothing to " + f"show. Not every result includes an image." + ) + + if isinstance(source, str) and _is_url(source): + data = _fetch(source) + filename = filename or Path(source.split("?", 1)[0]).name or None + return resolve_media(data, media_type=media_type, filename=filename) + + return resolve_media(source, media_type=media_type, filename=filename) + + +def _result_items(source: Any) -> Optional[List[Any]]: + """ + Recognize a whole detection result as opposed to a single item. + + Args: + source: Any accepted source. + + Returns: + The result items when `source` is a `DetectionResult`, allowing `show()` to + render each in turn. It returns `None` for anything else, including a single + result item that carries its own `media_url`. + """ + items = getattr(source, "results", None) + if isinstance(items, list) and not hasattr(source, "media_url"): + return items + return None + + +def _in_notebook() -> bool: + """ + Determine whether we are in a Jupyter kernel that can render rich output. + + Terminal IPython reports a `TerminalInteractiveShell` and cannot display images. + It deliberately does not count as a notebook environment. + """ + try: + import IPython + except ImportError: + return False + + # Accessed off the module rather than imported by name: IPython does not re-export + # get_ipython in a way type checkers recognize + get_ipython = getattr(IPython, "get_ipython", None) + if get_ipython is None: + return False + + shell = get_ipython() + return shell is not None and type(shell).__name__ == "ZMQInteractiveShell" + + +def _open_in_viewer(data: bytes, media_type: MediaType, filename: str) -> Path: + """ + Write to a temporary file and hand it to the operating system. + + The file is not cleaned up. Viewers open asynchronously, so deleting it here would + race the application that is about to read it. Everything lands in one + `guard-media-` directory so it is obvious what to purge. + """ + directory = Path(tempfile.mkdtemp(prefix="guard-media-")) + suffix = Path(filename).suffix or _EXTENSIONS.get(media_type, "") + path = directory / (Path(filename).stem or "media") + path = path.with_suffix(suffix) + path.write_bytes(data) + + try: + if sys.platform == "darwin": + subprocess.run(["open", str(path)], check=False) + elif sys.platform == "win32": + os.startfile(str(path)) # type: ignore[attr-defined] + else: + subprocess.run(["xdg-open", str(path)], check=False) + except OSError as exc: # pragma: no cover, platform dependent + raise GuardError(f"Could not open a viewer for {path}: {exc}") from exc + return path + + +def show( + source: DisplaySource, + *, + media_type: Optional[MediaType] = None, + filename: Optional[str] = None, + width: Optional[int] = None, + open_viewer: bool = True, +) -> None: + """ + Display media inline in Jupyter or in the system viewer elsewhere. + + Args: + source: A path, `bytes`, a URL, a result item, a `Share`, or a whole + `DetectionResult`. A full result shows every item that has an image. + media_type: Skips detection when you already know the type. + filename: Used for detection and for naming the temporary file. + width: Display width in pixels. This is honored only for inline rendering. + open_viewer: Set `False` to suppress launching an application. This is worth + doing in CI environments where spawning a viewer on a build agent is + unwelcome. + + Raises: + GuardError: The source carries no media or could not be fetched. + + Example: + ```python + show("photo.jpg") + show(result.results[0]) + show(result, width=400) + ``` + + Note: + HEIC and QuickTime cannot be rendered by most browsers. They will use the system + viewer even inside a notebook. + """ + items = _result_items(source) + if items is not None: + shown = 0 + for item in items: + if getattr(item, "media_url", None): + show(item, width=width, open_viewer=open_viewer) + shown += 1 + if not shown: + print("No result in this detection carries an image.") + return + + data, resolved_type, name = load_media( + source, media_type=media_type, filename=filename + ) + + if _in_notebook(): + if resolved_type in BROWSER_RENDERABLE: + _display_inline(data, resolved_type, width) + return + print( + f"{resolved_type.value} cannot be rendered inline by most browsers; " + f"opening it in the system viewer instead." + ) + + if not open_viewer: + return + _open_in_viewer(data, resolved_type, name) + + +def _display_inline(data: bytes, media_type: MediaType, width: Optional[int]) -> None: + """ + Embed the bytes in notebook output. + + Embedding rather than linking keeps the output working after the notebook is shared + or the media expires. It also keeps the media URL out of the saved `.ipynb` file. + """ + from IPython.display import Image, Video, display + + if media_type in _VIDEO_TYPES: + display(Video(data=data, embed=True, mimetype=media_type.value, width=width)) + else: + display(Image(data=data, width=width)) + + +def save( + source: DisplaySource, + path: Union[str, os.PathLike[str]], + *, + media_type: Optional[MediaType] = None, + filename: Optional[str] = None, + overwrite: bool = True, +) -> Path: + """ + Write media to disk while never opening a viewer. + + Args: + source: Anything `load_media` accepts. + path: A file path, or a directory to write into. When writing to a directory, + the name comes from the source and the extension from its media type. + media_type: Skips detection when you already know the type. + filename: Overrides the name used when `path` is a directory. + overwrite: Set `False` to refuse replacing an existing file. + + Returns: + The path actually written. + + Raises: + GuardError: The source carries no media, or the target exists and + `overwrite` is `False`. + """ + data, resolved_type, name = load_media( + source, media_type=media_type, filename=filename + ) + + target = Path(os.fspath(path)) + if target.is_dir() or str(path).endswith((os.sep, "/")): + target = target / name + if not target.suffix: + target = target.with_suffix( + mimetypes.guess_extension(resolved_type.value) + or _EXTENSIONS.get(resolved_type, "") + ) + + if target.exists() and not overwrite: + raise GuardError(f"{target} already exists. Pass overwrite=True to replace it.") + + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + return target diff --git a/src/guard_client/env.py b/src/guard_client/env.py new file mode 100644 index 0000000..22c0b98 --- /dev/null +++ b/src/guard_client/env.py @@ -0,0 +1,285 @@ +""" +Resolves settings across explicit arguments, the environment, and a `.env` file. + +Every `GuardClient` setting can come from any of three places. Precedence resolves in +this order, highest first: 1. Explicit argument. 2. Real environment variable. 3. `.env` +file. 4. Built-in default. + +Real environment variables deliberately override the `.env` file. A stale local `.env` +file must never shadow a secret injected by a CI pipeline or a container runtime. + +| Variable | Argument | Default | +| ------------------------ | ------------------ | ------------------------------------| +| `GUARD_API_KEY` | `api_key` | required for every cloud request | +| `GUARD_SPACE_ID` | `space_id` | required for cloud | +| `GUARD_ORGANIZATION_ID` | `organization_id` | default owner for spaces or runners | +| `GUARD_BASE_URL` | `base_url` | `https://api.elhio.com` | +| `GUARD_ENGINE` | `engine` | `cloud` | +| `GUARD_LOCALE` | `locale` | `en` | +| `GUARD_TIMEOUT` | `timeout` | `30.0` | +| `GUARD_MAX_RETRIES` | `max_retries` | `3` | +| `GUARD_LOCAL_MODEL_PATH` | `local_model_path` | unset | +| `GUARD_ENV_FILE` | `env_file` | `.env` | + +Copy `.env.example` to `.env` and fill it in. The `.env` file is git-ignored so secrets +stay on your machine. + +Examples: + ```python + from guard_client import GuardClient + + with GuardClient() as client: + # The .env file supplies the key and space. + result = client.analyze("photo.jpg") + + GuardClient(env_file=None) # doctest: +SKIP + GuardClient(env_file=".env.staging") # doctest: +SKIP + ``` + +Note: + Reading a `.env` file never writes to `os.environ`. Values are held in a plain dict + on the `EnvSource`. Because of this, constructing a client cannot surprise anything + else running in the same process. + +Tip: + Reading a `.env` file never writes to `os.environ`. Values are held in a plain dict + on the `EnvSource`. Because of this, constructing a client cannot surprise anything + else running in the same process. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Callable, Dict, Optional, Tuple, TypeVar, Union + +from dotenv import dotenv_values, find_dotenv + +from .exceptions import GuardError + +__all__ = ["DEFAULT_ENV_FILE", "ENV_FILE_VAR", "EnvSource", "read_env_file"] + +#: The file looked for when no path is explicitly given. +DEFAULT_ENV_FILE = ".env" + +#: Selects which env file to read. This is resolved from the real environment only. It +#: names the file so it cannot come from inside the file itself. +ENV_FILE_VAR = "GUARD_ENV_FILE" + +#: A generic type variable used for casting environment values. +T = TypeVar("T") + +#: A generic type variable used for casting environment values. +EnvFile = Optional[Union[str, "os.PathLike[str]"]] + + +def read_env_file(env_file: EnvFile = DEFAULT_ENV_FILE) -> Dict[str, str]: + """ + Parse an env file into a dict without touching `os.environ`. + + Args: + env_file: Path to the file. When left at the default, the file is discovered by + walking up from the current directory. Pass `None` to skip reading entirely + and get an empty dict. + + Returns: + The key/value pairs found in the file. Entries with no value are dropped. This + ensures a bare `GUARD_API_KEY=` falls through to the next layer instead of + yielding an empty string. + + Raises: + GuardError: If an explicitly named file does not exist. A discovered default + `.env` that is simply absent is not an error. + """ + if env_file is None: + return {} + + if str(env_file) != DEFAULT_ENV_FILE: + # named explicitly: a missing file is a typo, not a valid state + path = Path(os.fspath(env_file)) + if not path.is_file(): + raise GuardError(f"env file not found: {path}") + resolved = str(path) + else: + # walk up from the cwd so the file is still found from a subdirectory + resolved = find_dotenv(DEFAULT_ENV_FILE, usecwd=True) + if not resolved: + # no .env at all is perfectly normal in production + return {} + + return {key: value for key, value in dotenv_values(resolved).items() if value} + + +class EnvSource: + """ + Resolves individual settings against one loaded `.env` file. + + The file is read once on construction. The resulting dict is reused for every + subsequent lookup. + """ + + def __init__(self, env_file: EnvFile = DEFAULT_ENV_FILE) -> None: + """ + Read the env file once, making it ready for repeated lookups. + + Args: + env_file: Which file to read. Passing `None` disables file reading entirely. + The default is discovered by walking up from the current directory. + + Raises: + GuardError: An explicitly named file does not exist. + """ + # GUARD_ENV_FILE can redirect to another file, but only from the real + # environment. A file cannot nominate itself. + if env_file is not None and str(env_file) == DEFAULT_ENV_FILE: + env_file = os.environ.get(ENV_FILE_VAR) or DEFAULT_ENV_FILE + self._env_file = env_file + self._values = read_env_file(env_file) + + @property + def values(self) -> Dict[str, str]: + """ + The parsed file contents. + + Returns: + A copy of the key/value pairs. This ensures mutating it cannot corrupt the + source. This returns an empty dictionary when no file was read. + """ + return dict(self._values) + + def _lookup(self, name: str) -> Tuple[Optional[str], str]: + """ + Find a variable in the environment, and then check the file. + + Args: + name: The variable to look for. + + Returns: + The raw value and a human-readable source for error messages. Returns + `(None, "")` when neither layer has it. + + Note: + An empty string counts as unset. A bare `GUARD_API_KEY=` falls through + rather than producing an empty bearer token. + """ + from_env = os.environ.get(name) + if from_env: + return from_env, "the environment" + from_file = self._values.get(name) + if from_file: + return from_file, str(self._env_file or DEFAULT_ENV_FILE) + return None, "" + + def get( + self, + name: str, + explicit: Optional[T] = None, + *, + default: Optional[T] = None, + cast: Optional[Callable[[str], T]] = None, + ) -> Optional[T]: + """ + Resolve one setting. + + Args: + name: The `GUARD_*` variable to look for. + explicit: The value passed to the constructor. `None` means it was not + given, which is why every env-backed parameter defaults to `None`. + default: Used when neither the environment nor the file supplies a value. + cast: Converts the raw string. Omit this for plain strings. + + Returns: + The resolved setting. This will be the explicit value if provided, the + value from the environment or `.env` file (cast to the target type if + requested), or the default value if nothing was found. + + Raises: + GuardError: If `cast` rejects the value, naming the variable and its source. + """ + if explicit is not None: + return explicit + + raw, source = self._lookup(name) + if raw is None: + return default + if cast is None: + return raw # type: ignore[return-value] + + try: + return cast(raw) + except (TypeError, ValueError) as exc: + expected = getattr(cast, "__name__", "value") + raise GuardError( + f"{name}={raw!r} (from {source}) is not a valid {expected}: {exc}" + ) from exc + + def get_optional(self, name: str, explicit: Optional[str] = None) -> Optional[str]: + """ + Resolve a string setting that has no default. + + This is deliberately non-generic. `get` cannot infer a useful type parameter for + a union-typed argument such as `space_id` and widens it to `object`. + + Args: + name: The `GUARD_*` variable to look for. + explicit: The value passed to the constructor, or `None` if omitted. + + Returns: + The resolved string value, or `None` if it was not found in the environment + or file and no explicit value was provided. + """ + if explicit is not None: + return explicit + raw, _ = self._lookup(name) + return raw + + def get_str(self, name: str, explicit: Optional[str], default: str) -> str: + """ + Resolve a string setting that has a default. + + Args: + name: The `GUARD_*` variable to look for. + explicit: The value passed to the constructor, or `None` if omitted. + default: Used when no layer supplies a value. + + Returns: + The resolved string, which is never `None`. + """ + value = self.get(name, explicit, default=default) + return default if value is None else value + + def get_float(self, name: str, explicit: Optional[float], default: float) -> float: + """ + Resolve a float setting that has a default. + + Args: + name: The `GUARD_*` variable to look for. + explicit: The value passed to the constructor, or `None` if omitted. + default: Used when no layer supplies a value. + + Returns: + The resolved number, which is never `None`. + + Raises: + GuardError: If the value could not be parsed as a number. + """ + value = self.get(name, explicit, default=default, cast=float) + return default if value is None else value + + def get_int(self, name: str, explicit: Optional[int], default: int) -> int: + """ + Resolve an integer setting that has a default. + + Args: + name: The `GUARD_*` variable to look for. + explicit: The value passed to the constructor, or `None` if omitted. + default: Used when no layer supplies a value. + + Returns: + The resolved integer, which is never `None`. + + Raises: + GuardError: If the value could not be parsed as an integer. + """ + value = self.get(name, explicit, default=default, cast=int) + return default if value is None else value diff --git a/src/guard_client/exceptions.py b/src/guard_client/exceptions.py index 9ef664a..e4e0927 100644 --- a/src/guard_client/exceptions.py +++ b/src/guard_client/exceptions.py @@ -1 +1,397 @@ -# TODO: Custom error classes (e.g., GuardAPIError, AuthError) \ No newline at end of file +""" +Exception hierarchy for the Guard client. + +Everything raised by this package derives from `GuardError`. This ensures a single +`except GuardError` block catches any client failure. Below that base class, errors +split into two families: those raised locally before a request is sent, and +`GuardAPIError` subclasses carrying a status code the server returned. + +Catching specific exceptions is worthwhile when the response dictates a different +action. A `GuardConflictError` usually means the action was already done, which is +often benign, while a `GuardPaymentRequiredError` needs human intervention. + +The on-device engine forms a third family under `GuardLocalEngineError`. The +`guard-local-detector` package raises its own hierarchy rooted at +`guard_local.GuardLocalError`, which does not derive from anything in this package. The +`guard_client.local` module translates each of those errors into the appropriate +`GuardLocalEngineError` subclass before it reaches a caller. This keeps the promise +that `except GuardError` will catch everything this client can raise regardless of which +engine ran. + +Examples: + ```python + from guard_client import ( + ActivityFailedError, + GuardAuthError, + GuardError, + GuardTimeoutError, + ) + + try: + result = client.analyze("photo.jpg") + except GuardAuthError: + pass # Handle bad or expired API key + except ActivityFailedError as exc: + pass # Processing ended "failed" or "canceled". exc.status says which. + except GuardTimeoutError: + pass # Still processing when the deadline passed + except GuardError: + pass # Anything else from this client + ``` + +Note: + Values are validated locally wherever the rule is knowable without the server. Most + mistakes raise a plain `GuardError` before any request is sent. This is deliberate + because a wasted round-trip produces a worse error message than a local check. +""" + +from __future__ import annotations + +from typing import Any, List, Optional +from uuid import UUID + +__all__ = [ + "ActivityFailedError", + "GuardAPIError", + "GuardAuthError", + "GuardConflictError", + "GuardConnectionError", + "GuardError", + "GuardLocalEngineError", + "GuardLocalModelError", + "GuardMediaDecodeError", + "GuardNotFoundError", + "GuardPaymentRequiredError", + "GuardRateLimitError", + "GuardServerError", + "GuardTimeoutError", + "GuardUploadError", + "GuardValidationError", + "LocalEngineNotInstalledError", + "UnsupportedMediaTypeError", +] + + +class GuardError(Exception): + """ + Base class for every error raised by this package. + + This is raised directly for local validation failures. These failures have no status + code because no request was made. + """ + + +class GuardAPIError(GuardError): + """ + The API returned a non-2xx response. + + This error is subclassed per status code. Catch this to handle any server-side + failure uniformly. + + Args: + message: Human-readable summary taken from the server `detail` when present. + status_code: The HTTP status that produced this error. + detail: The raw `detail` payload. This is a string for most errors, or a list + of per-field objects for 422 errors. + request_id: The server `x-request-id` when it sent one. This is worth quoting + in a bug report. + + Attributes: + status_code: The HTTP status that produced this error. + detail: The raw `detail` payload from the server. + request_id: The server `x-request-id`, or `None`. + """ + + def __init__( + self, + message: str, + *, + status_code: int, + detail: Any = None, + request_id: Optional[str] = None, + ) -> None: + """ + Store the response metadata alongside the message. + + Args: + message: Human-readable summary. + status_code: The HTTP status that produced this error. + detail: The raw `detail` payload. + request_id: The server `x-request-id` when present. + """ + super().__init__(message) + self.status_code = status_code + self.detail = detail + self.request_id = request_id + + def __str__(self) -> str: + """ + Render the message with its status code prefixed. + + Returns: + The server message behind a `[404]` style prefix. This ensures that a bare + print of the exception still indicates which status caused it. + """ + base = super().__str__() + return f"[{self.status_code}] {base}" if self.status_code else base + + +class GuardAuthError(GuardAPIError): + """ + The API key is missing, invalid, or lacks permission (401/403). + + This is also raised for 403 responses that represent authorization failures, such as + attempting to use a predictor that is not enabled in your plan. + """ + + +class GuardNotFoundError(GuardAPIError): + """ + The requested resource does not exist (404). + + Note: + Several routes answer 404 for resources that exist but do not belong to you. + This means a 404 does not strictly prove absence. Reacting to someone else's + activity reports the same error as reacting to an id that was never issued. + """ + + +class GuardValidationError(GuardAPIError): + """ + The request body or parameters failed server-side validation (422). + + Reaching this usually means a value was provided that the client could not check + locally. Anything knowable in advance is rejected before the request is made. + + Attributes: + errors: Per-field validation errors. + """ + + @property + def errors(self) -> List[Any]: + """ + Per-field validation errors or an empty list. + + Returns: + One mapping per rejected field, each containing `loc`, `msg`, and `type`. + Returns an empty list when the server sent a plain string instead. + """ + return self.detail if isinstance(self.detail, list) else [] + + +class GuardPaymentRequiredError(GuardAPIError): + """ + The account lacks an active subscription or a plan limit was reached (402). + + This covers both "no subscription" and "you already have as many spaces or runners + as your plan allows". The API does not distinguish between these scenarios by status + code. + """ + + +class GuardConflictError(GuardAPIError): + """ + The resource clashes with one that already exists (409). + + This is raised when a space or runner name is already taken in the same context, + when an activity result already has a reaction, or when an activity has already been + shared. + + Note: + This error is often benign. Because the client never retries a create operation, + seeing this means the resource genuinely existed beforehand, not that a request + was replayed. + """ + + +class GuardRateLimitError(GuardAPIError): + """ + Too many requests (429). + + Requests are retried automatically up to `max_retries` while honoring the + `Retry-After` header. This error surfaces only once those attempts are exhausted. + + Args: + *args: Forwarded to `GuardAPIError`. + retry_after: Seconds the server asked us to wait when it provided this + information. + **kwargs: Forwarded to `GuardAPIError`. + + Attributes: + retry_after: Seconds from the `Retry-After` header, or `None`. + """ + + def __init__( + self, *args: Any, retry_after: Optional[float] = None, **kwargs: Any + ) -> None: + """ + Store the server requested wait time alongside the response metadata. + + Args: + *args: Forwarded to `GuardAPIError`. + retry_after: Seconds from the `Retry-After` header. + **kwargs: Forwarded to `GuardAPIError`. + """ + super().__init__(*args, **kwargs) + self.retry_after = retry_after + + +class GuardServerError(GuardAPIError): + """ + The API failed to handle the request (5xx). + + These are retried automatically for idempotent calls. Create operations are never + replayed so this surfaces immediately for them. + """ + + +class GuardConnectionError(GuardError): + """ + The request never reached the API (DNS, TLS, socket, or read timeout). + + This is distinct from `GuardServerError` because nothing was processed. A create + operation that fails this way definitely did not happen. + """ + + +class GuardUploadError(GuardError): + """ + The presigned S3 upload was rejected. + + This is separate from `GuardAPIError` because the failing request went to storage + rather than the API, meaning it carries no `detail` payload. + + Args: + message: What went wrong. + status_code: The status storage returned when there was a response at all. + + Attributes: + status_code: The HTTP status from storage, or `None` for a transport failure. + """ + + def __init__(self, message: str, *, status_code: Optional[int] = None) -> None: + """ + Store the storage response status alongside the message. + + Args: + message: What went wrong. + status_code: The status storage returned, if any. + """ + super().__init__(message) + self.status_code = status_code + + +class GuardTimeoutError(GuardError): + """ + An activity did not reach a terminal status before the polling deadline. + + The activity is still running server-side. Only the waiting process stopped. You + should fetch it again later rather than resubmitting the media. + + Args: + message: What timed out, including the last status seen. + activity_id: The activity that was being polled. + + Attributes: + activity_id: The activity still in flight so it can be polled again. + """ + + def __init__(self, message: str, *, activity_id: Optional[UUID] = None) -> None: + """ + Store which activity was still running when the deadline passed. + + Args: + message: What timed out, including the last status seen. + activity_id: The activity that was being polled. + """ + super().__init__(message) + self.activity_id = activity_id + + +class ActivityFailedError(GuardError): + """ + An activity reached a terminal status other than `completed`. + + Args: + message: What happened, naming the activity and status. + status: The terminal status reached, either `failed` or `canceled`. + activity_id: The activity that ended. + + Attributes: + status: The terminal status reached. + activity_id: The activity that ended. + """ + + def __init__( + self, message: str, *, status: str, activity_id: Optional[UUID] = None + ) -> None: + """ + Store which activity ended and how. + + Args: + message: What happened, naming the activity and status. + status: The terminal status reached. + activity_id: The activity that ended. + """ + super().__init__(message) + self.status = status + self.activity_id = activity_id + + +class UnsupportedMediaTypeError(GuardError, ValueError): + """ + The media MIME type is not one the backend accepts. + + This subclasses `ValueError` because it is a bad argument rather than a service + failure. It is raised locally before any upload happens. + """ + + +class GuardLocalEngineError(GuardError): + """ + The on-device engine failed. + + Every failure raised by `guard-local-detector` is translated into this class or one + of its subclasses before it leaves `LocalRunner`. The engine's own exceptions do not + derive from `GuardError`. Without translation, a caller writing `except GuardError` + would catch every cloud failure but miss every local one. + + Catch this to handle any local-engine problem uniformly. Catch a subclass when the + remedy differs. For example, a missing extra needs an install but a missing model + file does not. + """ + + +class LocalEngineNotInstalledError(GuardLocalEngineError, ImportError): + """ + Local execution was requested without the optional `[local]` extra. + + This also subclasses `ImportError` so existing error handling for a missing optional + dependency catches it. The message distinguishes an absent package from one that is + installed but broken. + """ + + +class GuardLocalModelError(GuardLocalEngineError): + """ + The engine is installed but its detection model could not be loaded. + + This is distinct from `LocalEngineNotInstalledError` because the remedy is + different. The package is present and importable, so telling the user to install + the extra is wrong advice for a model file that is missing, unreadable, or corrupt. + + Note: + This is raised on the first call rather than at construction because the engine + defers loading the model until something needs scoring. + """ + + +class GuardMediaDecodeError(GuardLocalEngineError, ValueError): + """ + The engine accepts this media type but could not decode the bytes. + + This covers truncated or corrupt files, as well as videos that yield no decodable + frames. It also subclasses `ValueError` because unreadable input is a bad argument + rather than an engine failure. This follows the same reasoning that puts + `UnsupportedMediaTypeError` under `ValueError`. + """ diff --git a/src/guard_client/filters.py b/src/guard_client/filters.py new file mode 100644 index 0000000..9dbc044 --- /dev/null +++ b/src/guard_client/filters.py @@ -0,0 +1,383 @@ +""" +Shared query-parameter helpers for the list endpoints. + +Filters are validated here on the client side. This ensures that a typo fails +immediately with the valid options listed rather than returning a 422 or 400 error from +the server. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone +from enum import Enum +from typing import Any, Collection, Dict, List, Optional, Sequence, Type, TypeVar, Union +from uuid import UUID + +from .exceptions import GuardError +from .models import SortOrder, coerce_enum, ensure_bool + +__all__ = [ + "MAX_HISTORY", + "MAX_LIMIT", + "DateLike", + "IdLike", + "add_bool", + "add_datetime", + "add_ids", + "add_sort", + "add_statuses", + "id_list", + "reject_conflicting_owners", + "reject_too_old", + "require_exactly_one_owner", + "validate_length", + "validate_pagination", +] + +#: The server caps a single page at this many items. +MAX_LIMIT = 100 + +#: How far back activity history reaches. This matches `one_year_ago` in the +#: `read_activities` endpoint of the API. +MAX_HISTORY = timedelta(days=365) + +#: Tolerance applied when checking `MAX_HISTORY` locally. The server computes its cutoff +#: from *its* clock, which is always a little later than ours by the time the request +#: lands. +_CLOCK_GRACE = timedelta(minutes=5) + +#: A type alias for UUID-like inputs. +IdLike = Union[UUID, str] + +#: A type alias for date-like inputs. +DateLike = Union[datetime, date, str] + +#: A type variable for enum validation. +E = TypeVar("E", bound=Enum) + + +def validate_pagination(skip: int, limit: int) -> None: + """ + Check `skip` and `limit` against the range accepted by the server. + + Args: + skip: The number of items to skip. + limit: The maximum number of items to return. + + Raises: + GuardError: If `skip` is negative or `limit` is outside 1-100. + """ + if skip < 0: + raise GuardError(f"Invalid skip={skip}. Expected 0 or greater") + if not 1 <= limit <= MAX_LIMIT: + raise GuardError(f"Invalid limit={limit}. Expected between 1 and {MAX_LIMIT}") + + +def reject_conflicting_owners( + user_id: Optional[IdLike], organization_id: Optional[IdLike] +) -> None: + """ + Mirror the server rejection of both owner filters at once. + + The API answers 400 when given both. Failing here saves the round-trip. + + Args: + user_id: The user ID to filter by. + organization_id: The organization ID to filter by. + + Raises: + GuardError: If both `user_id` and `organization_id` are provided. + """ + if user_id is not None and organization_id is not None: + raise GuardError( + "Cannot filter by both user_id and organization_id at the same time. " + "Pass whichever one you mean." + ) + + +def require_exactly_one_owner( + user_id: Optional[IdLike], organization_id: Optional[IdLike] +) -> None: + """ + Verify that a space belongs to exactly one owner. + + A space belongs to exactly one owner. It must be either a user or an organization, + but never both. The server answers 400 for either mistake. Checking here saves the + round-trip. + + Args: + user_id: The user ID of the owner. + organization_id: The organization ID of the owner. + + Raises: + GuardError: If neither or both were given. + """ + if user_id is not None and organization_id is not None: + raise GuardError( + "A space cannot belong to both a user and an organization. " + "Pass exactly one of user_id or organization_id." + ) + if user_id is None and organization_id is None: + raise GuardError( + "A space needs an owner. Pass exactly one of user_id or organization_id " + "(an existing space from spaces.list() shows which ids are available)." + ) + + +def validate_length( + value: str, *, field: str, min_len: int = 0, max_len: Optional[int] = None +) -> str: + """ + Check the length of a string that has already been stripped by the caller. + + Stripping the string first is important because the server also strips it. To the + server, `" ab "` is two characters, not six. Validating the raw string would allow + a name that is too short to pass through. + + Args: + value: The string to validate. + field: The name of the field for use in error messages. + min_len: The minimum allowed length. Defaults to 0. + max_len: The maximum allowed length, or `None` for no maximum limit. + + Returns: + The original string if it passes validation. + + Raises: + GuardError: If the value is outside the allowed length. + """ + if len(value) < min_len: + raise GuardError( + f"Invalid {field}={value!r}. Expected at least {min_len} characters, " + f"got {len(value)}" + ) + if max_len is not None and len(value) > max_len: + raise GuardError( + f"Invalid {field}. Expected at most {max_len} characters, got {len(value)}" + ) + return value + + +def id_list(values: Optional[Sequence[IdLike]], *, field: str) -> Optional[List[str]]: + """ + Stringify a sequence of ids, dropping duplicates but keeping order. + + This mirrors the server's `dict.fromkeys` deduplication so the client and server + agree on what was sent. It returns `None` when nothing was supplied, allowing the + caller to omit the key rather than send an empty list. + + Args: + values: A sequence of IDs to process. + field: The name of the field for use in error messages. + + Returns: + A deduplicated list of IDs as strings, or `None` if the input was `None`. + + Raises: + GuardError: An entry is empty or not usable as an id. + """ + if values is None: + return None + seen: Dict[str, None] = {} + for value in values: + text = str(value).strip() + if not text: + raise GuardError(f"Invalid {field}: entries must be non-empty ids") + seen.setdefault(text, None) + return list(seen) + + +def add_ids(params: Dict[str, Any], **ids: Optional[IdLike]) -> None: + """ + Add the supplied id filters as strings. + + Omitting an unset filter is necessary. Sending `None` would instruct the server to + match a null id rather than leave the field unfiltered. + + Args: + params: The query dict to add to. This is mutated in place. + **ids: Filter name mapped to its value. Entries that are `None` are skipped. + """ + for name, value in ids.items(): + if value is not None: + params[name] = str(value) + + +def add_bool(params: Dict[str, Any], name: str, value: Optional[bool]) -> None: + """ + Add a boolean filter. + + Args: + params: The query dict to add to. This is mutated in place. + name: The query parameter name. + value: Pass `True` or `False` to filter, or `None` to leave the field + unfiltered. + + Raises: + GuardError: If `value` is neither a boolean nor `None`. + + Note: + The `httpx` library serializes real booleans to `"true"` or `"false"`. Stand-ins + like `1` or `"false"` are refused rather than guessed at. Because `"false"` is + a truthy string in Python, silently inverting a filter is worse than raising an + error. + """ + if value is not None: + # httpx serialises real bools to "true"/"false" + params[name] = ensure_bool(value, field=name) + + +def add_statuses( + params: Dict[str, Any], + statuses: Optional[Sequence[Union[E, str]]], + enum_cls: Type[E], + *, + allowed: Optional[Collection[E]] = None, +) -> None: + """ + Add a repeated `statuses` filter while validating every entry. + + Args: + params: The query dict to add to. This is mutated in place. + statuses: Enum members or their string values. Passing `None` or an empty + sequence leaves the field unfiltered. + enum_cls: The status enum this resource parses responses with. + allowed: Restricts which members may be used as a filter. Some resources model + responses on a wider enum than they accept for filtering. For instance, a + runner can return `terminated`, but you cannot search for that status. This + means the parsing enum and the filterable set are not always the same. + + Raises: + GuardError: If an entry is not a member of `enum_cls` or is excluded by + `allowed`. The error message lists the values that would be accepted. + """ + if not statuses: + return + values: List[str] = [] + for status in statuses: + member = coerce_enum(status, enum_cls, field="statuses") + if allowed is not None and member not in allowed: + valid = ", ".join(repr(m.value) for m in enum_cls if m in allowed) + raise GuardError( + f"Invalid statuses={member.value!r}. Expected one of: {valid}" + ) + values.append(str(member.value)) + params["statuses"] = values + + +def add_sort( + params: Dict[str, Any], + sort_by: Optional[Union[E, str]], + sort_order: Optional[Union[SortOrder, str]], + order_cls: Type[E], +) -> None: + """ + Add `sort_by` and `sort_order` when given. + + Leaving them out lets the server apply its own default. The default differs per + resource. Spaces sort ascending, while activities and shares sort descending. + + Args: + params: The query dict to add to. This is mutated in place. + sort_by: An ordering enum member or its string value. + sort_order: `"asc"`, `"desc"`, or a `SortOrder` member. + order_cls: The ordering enum valid for this resource. + + Raises: + GuardError: If either value is not a member of its enum. The error message lists + the valid options. + """ + if sort_by is not None: + params["sort_by"] = coerce_enum(sort_by, order_cls, field="sort_by").value + if sort_order is not None: + params["sort_order"] = coerce_enum( + sort_order, SortOrder, field="sort_order" + ).value + + +def add_datetime(params: Dict[str, Any], name: str, value: Optional[DateLike]) -> None: + """ + Add a date filter as ISO-8601. + + Args: + params: The query dict to add to. This is mutated in place. + name: The query parameter name. + value: A `datetime`, a `date`, or a string already in ISO-8601 format. Passing + `None` leaves the field unfiltered. + + Note: + Strings pass through untouched, meaning an offset the caller supplied is + preserved. A naive `datetime` is sent without an offset, which the server reads + as UTC. + """ + if value is None: + return + if isinstance(value, (datetime, date)): + params[name] = value.isoformat() + else: + params[name] = str(value) + + +def _as_utc_datetime(value: DateLike) -> Optional[datetime]: + """ + Perform a best-effort conversion to an aware UTC datetime. + + Args: + value: The date or time to parse. + + Returns: + An aware UTC datetime, or `None` if the string could not be parsed. This leaves + format validation to the server rather than guessing at the caller's intent. + """ + # datetime is a subclass of date, so it has to be tested first + if isinstance(value, datetime): + parsed = value + elif isinstance(value, date): + parsed = datetime(value.year, value.month, value.day) + else: + text = str(value).strip() + # Python 3.9-3.10's fromisoformat does not accept a trailing "Z" + if text.endswith(("Z", "z")): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + + # server treats a naive datetime as UTC; match it rather than assume local time + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def reject_too_old( + value: Optional[DateLike], *, field: str, max_age: timedelta = MAX_HISTORY +) -> None: + """ + Reject a date beyond the API retention window. + + The API keeps a limited history and answers 400 for anything older. Checking here + turns that into an immediate, self-explanatory error. Unparseable strings are left + alone because the server validates the format. + + Args: + value: The date to check. + field: The name of the field for use in error messages. + max_age: The maximum allowed age. Defaults to `MAX_HISTORY`. + + Raises: + GuardError: If `value` predates the retention window. + """ + if value is None: + return + moment = _as_utc_datetime(value) + if moment is None: + return + + oldest = datetime.now(timezone.utc) - max_age - _CLOCK_GRACE + if moment < oldest: + raise GuardError( + f"Invalid {field}={value!r}. The API keeps only {max_age.days} days of " + f"history; the oldest queryable date is about " + f"{oldest.date().isoformat()}" + ) diff --git a/src/guard_client/local.py b/src/guard_client/local.py new file mode 100644 index 0000000..4faba50 --- /dev/null +++ b/src/guard_client/local.py @@ -0,0 +1,422 @@ +""" +Wrapper around the optional on-device detection engine. + +The engine itself lives in a separate repo and ships as the `guard-local-detector` +package (import name `guard_local`). This package is AGPL-3.0 licensed. It is therefore +an opt-in extra and is imported lazily inside the call. Importing `guard_client` must +never require it. + +This module manages the adaptation from the engine's raw output to `DetectionResult`. +This ensures the rest of the package never touches `guard_local` types. +""" + +from __future__ import annotations + +import asyncio +from contextlib import contextmanager +from typing import Any, Dict, Iterator, List, Optional, Protocol, Type, Union +from uuid import UUID, uuid5 + +from pydantic import ValidationError + +from .exceptions import ( + GuardError, + GuardLocalEngineError, + GuardLocalModelError, + GuardMediaDecodeError, + LocalEngineNotInstalledError, + UnsupportedMediaTypeError, +) +from .media import MediaSource, resolve_media +from .models import ( + ActivityResultItem, + DetectionMatch, + DetectionResult, + Engine, + MediaType, +) + +__all__ = ["LocalEngine", "LocalRunner"] + +#: The hint shown when the local engine is requested but not installed. +_INSTALL_HINT = ( + "The local engine is an optional extra. Install it with:\n" + ' pip install "guard-client[local]"' +) + +#: A fixed UUID namespace used to derive stable task IDs for local runs. Local runs have +#: no server-assigned task IDs, so we derive them from the label. A fixed namespace +#: keeps them reproducible across processes and releases. +_LOCAL_TASK_NAMESPACE = UUID("6f9619ff-8b86-d011-b42d-00c04fc964ff") + +#: Maps the excepti raises for the same mistake, rather than a local-only +#: twin of it.on name from `guard_local.exceptions` to the `GuardError` raised in +#: its place. The engine's hierarchy derives from nothing here, so without this map an +#: `except GuardError` would miss every local failure. `UnsupportedMediaError` maps onto +#: the type the cloud path already +_LOCAL_ERROR_MAP: Dict[str, Type[GuardError]] = { + "UnsupportedMediaError": UnsupportedMediaTypeError, + "MediaDecodeError": GuardMediaDecodeError, + "ModelLoadError": GuardLocalModelError, + "GuardLocalError": GuardLocalEngineError, +} + + +def _map_local_error(exc: BaseException) -> Optional[GuardError]: + """ + Find the `GuardError` standing in for an engine exception. + + This matches on the MRO by module and class name rather than with `isinstance`. + Importing `guard_local` to get the classes would break the lazy-import guarantee for + every caller who never asked for the local engine. It would also stop the tests that + drive fakes. + + Args: + exc: Whatever escaped the engine. + + Returns: + The replacement exception to raise, or `None` when `exc` did not come from + `guard_local` and should propagate untouched. MRO order ensures the most + specific match wins, so a `MediaDecodeError` resolves before its + `GuardLocalError` base class. + """ + for klass in type(exc).__mro__: + # Name alone is too weak. If an engine re-raises some other library's + # ModelLoadError, it must not be relabelled as ours + if klass.__module__.split(".")[0] != "guard_local": + continue + target = _LOCAL_ERROR_MAP.get(klass.__name__) + if target is not None: + return target(str(exc)) + return None + + +@contextmanager +def _mapped_local_errors() -> Iterator[None]: + """ + Translate engine exceptions into `GuardError` for the enclosed block. + + Yields: + Nothing. This context manager exists only for its `except` clause. + + Raises: + GuardLocalEngineError: Or one of its subclasses in place of the engine's own + exception, which is kept as `__cause__`. Anything not raised by + `guard_local` propagates unchanged. A bug in the engine should surface as + the bug it is rather than being disguised as a client error. + """ + try: + yield + except Exception as exc: + mapped = _map_local_error(exc) + if mapped is None: + raise + raise mapped from exc + + +class LocalEngine(Protocol): + """ + The contract this package expects of the local engine. + + `guard-local-detector` is still in development. Coding against this protocol + keeps the adapter stable while that repo settles. It also lets the tests + substitute a fake engine so they can run without the ONNX runtime installed. + """ + + def analyze(self, data: bytes, media_type: str) -> Dict[str, Any]: + """ + Score media bytes. + + Args: + data: The media to analyse. + media_type: Its MIME type. + + Returns: + Raw engine output that will be adapted by this module into the shared result + shape. + """ + ... + + async def analyze_async(self, data: bytes, media_type: str) -> Dict[str, Any]: + """ + Score media bytes without blocking the event loop. + + Args: + data: The media to analyse. + media_type: Its MIME type. + + Returns: + Raw engine output, the same as what `analyze` returns. + """ + ... + + +def _load_engine(model_path: Optional[str] = None) -> Any: + """ + Import and construct the local engine. + + Args: + model_path: Where the ONNX model lives when the engine requires it. + + Returns: + A constructed engine satisfying the `LocalEngine` protocol. + + Raises: + LocalEngineNotInstalledError: If the package is absent, one of its dependencies + is absent, or it is installed but unimportable. The error message + distinguishes these scenarios because telling a user to install the extra + is unhelpful advice if it is already installed. + """ + try: + import guard_local + except ModuleNotFoundError as exc: + # engine package itself is absent: the common case, fixed by the extra + if exc.name == "guard_local": + raise LocalEngineNotInstalledError( + f"{_INSTALL_HINT}\n\n(original error: {exc})" + ) from exc + # something the engine depends on is missing: its install is incomplete + raise LocalEngineNotInstalledError( + f"guard-local-detector is installed but its dependency {exc.name!r} is " + f"not. Reinstall it with: " + f'pip install --force-reinstall "guard-client[local]"' + f"\n\n(original error: {exc})" + ) from exc + except ImportError as exc: + # package exists but failed to import + raise LocalEngineNotInstalledError( + "guard-local-detector is installed but could not be imported. " + f'Reinstall it with: pip install --force-reinstall "guard-client[local]"' + f"\n\n(original error: {exc})" + ) from exc + + factory = getattr(guard_local, "LocalDetectorEngine", None) + if factory is None: + raise LocalEngineNotInstalledError( + "The installed guard-local-detector does not expose LocalDetectorEngine. " + "Upgrade it with: pip install --upgrade 'guard-client[local]'" + ) + return factory(model_path) if model_path is not None else factory() + + +def _score_to_int(value: Any) -> int: + """ + Normalize an engine score to the 0-100 integer scale of the API. + + The engine reports a 0-1 probability while the cloud API reports 0-100. A value that + fits in the unit interval is rescaled. Anything already above 1 is assumed to be on + the 0-100 scale and is simply clamped. + + Args: + value: The raw score from the engine. + + Returns: + An integer between 0 and 100. + """ + try: + numeric = float(value) + except (TypeError, ValueError): + return 0 + if 0.0 <= numeric <= 1.0: + numeric *= 100.0 + return max(0, min(100, round(numeric))) + + +def _matches(value: Any) -> Optional[List[DetectionMatch]]: + """ + Read the evidence list from the engine while tolerating invalid entries. + + Args: + value: Whatever the engine provided under the `matches` key. + + Returns: + The parsed evidence, or `None` when the engine reported no evidence. Entries + that do not parse are skipped rather than raising an error. Partial evidence is + still worth more than a failed analysis, which mirrors how the rest of `_adapt` + treats malformed data. + """ + if not isinstance(value, (list, tuple)): + return None + matches = [] + for entry in value: + if not isinstance(entry, dict): + continue + try: + matches.append(DetectionMatch.model_validate(entry)) + except ValidationError: + continue + return matches + + +def _adapt(raw: Any) -> List[ActivityResultItem]: + """ + Turn raw engine output into the items the cloud API returns. + + This function accepts either a single mapping or a sequence of them because the + engine output shape is not yet frozen. The `detected` and `matches` fields are + carried through when the engine reports them and left as `None` when it does not, + matching the behavior of the cloud path. + + Args: + raw: The raw output returned by the engine. + + Returns: + A list of standardized `ActivityResultItem` objects. + """ + if raw is None: + return [] + entries = raw if isinstance(raw, (list, tuple)) else [raw] + + items: List[ActivityResultItem] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + label = str(entry.get("label") or entry.get("status") or "unknown") + raw_task_id = entry.get("task_id") + try: + task_id = ( + UUID(str(raw_task_id)) + if raw_task_id + else uuid5(_LOCAL_TASK_NAMESPACE, label) + ) + except ValueError: + task_id = uuid5(_LOCAL_TASK_NAMESPACE, label) + + detected = entry.get("detected") + items.append( + ActivityResultItem( + task_id=task_id, + score=_score_to_int(entry.get("score")), + label=label, + description=entry.get("description"), + media_key=entry.get("media_key"), + media_url=entry.get("media_url"), + detected=detected if isinstance(detected, bool) else None, + matches=_matches(entry.get("matches")), + ) + ) + return items + + +class LocalRunner: + """ + Lazily loads and caches one engine instance, then adapts its output. + + The ONNX session load is an expensive operation. To mitigate this, the engine is + constructed on its first use and reused for the lifetime of the client that owns + this runner. + """ + + def __init__( + self, *, model_path: Optional[str] = None, engine: Optional[Any] = None + ) -> None: + """ + Prepare a runner without loading anything yet. + + Args: + model_path: Where the ONNX model lives. This is passed through to the + engine. + engine: An already-constructed engine. This is mainly used for tests which + substitute a fake engine so the test suite runs without the ONNX runtime + installed. + """ + self._model_path = model_path + self._engine = engine + + def _ensure_engine(self) -> Any: + """ + Load the engine on first use and reuse it subsequently. + + Returns: + The cached engine. It is constructed if this is the first call. + + Raises: + LocalEngineNotInstalledError: If the engine could not be loaded. + """ + if self._engine is None: + self._engine = _load_engine(self._model_path) + return self._engine + + def analyze( + self, + source: MediaSource, + *, + media_type: Optional[Union[MediaType, str]] = None, + filename: Optional[str] = None, + ) -> DetectionResult: + """ + Run on-device detection. + + Args: + source: A path, raw `bytes`, or an open binary file. + media_type: Skips MIME detection when you already know the type. + filename: Used for detection and error messages. + + Returns: + A `DetectionResult` with `engine=LOCAL` and no `activity_id` because nothing + was created server-side. + + Raises: + LocalEngineNotInstalledError: The optional engine is unavailable. + UnsupportedMediaTypeError: The media type is not one the API accepts or is + one the engine itself cannot score. + GuardMediaDecodeError: The bytes could not be decoded. + GuardLocalModelError: The detection model could not be loaded. + GuardLocalEngineError: Any other failure that occurs inside the engine. + """ + data, resolved_type, _ = resolve_media( + source, media_type=media_type, filename=filename + ) + with _mapped_local_errors(): + engine = self._ensure_engine() + raw = engine.analyze(data, resolved_type.value) + return DetectionResult( + engine=Engine.LOCAL, results=_adapt(raw), activity_id=None + ) + + async def analyze_async( + self, + source: MediaSource, + *, + media_type: Optional[Union[MediaType, str]] = None, + filename: Optional[str] = None, + ) -> DetectionResult: + """ + Run on-device detection without blocking the event loop. + + Args: + source: A path, raw `bytes`, or an open binary file. + media_type: Skips MIME detection when you already know the type. + filename: Used for detection and error messages. + + Returns: + A `DetectionResult` with `engine=LOCAL`. + + Raises: + LocalEngineNotInstalledError: The optional engine is unavailable. + UnsupportedMediaTypeError: The media type is not one the API accepts or is + one the engine itself cannot score. + GuardMediaDecodeError: The bytes could not be decoded. + GuardLocalModelError: The detection model could not be loaded. + GuardLocalEngineError: Any other failure that occurs inside the engine. + + Note: + If an engine offers no async entry point, the work is offloaded to a thread. + This ensures that inference never stalls the event loop. + """ + data, resolved_type, _ = resolve_media( + source, media_type=media_type, filename=filename + ) + with _mapped_local_errors(): + engine = self._ensure_engine() + + analyze_async = getattr(engine, "analyze_async", None) + if analyze_async is not None: + raw = await analyze_async(data, resolved_type.value) + else: + # engine is sync-only: offload so inference does not stall the loop + raw = await asyncio.to_thread(engine.analyze, data, resolved_type.value) + + return DetectionResult( + engine=Engine.LOCAL, results=_adapt(raw), activity_id=None + ) diff --git a/src/guard_client/media.py b/src/guard_client/media.py new file mode 100644 index 0000000..d829c4a --- /dev/null +++ b/src/guard_client/media.py @@ -0,0 +1,215 @@ +""" +Turns whatever the caller passed into bytes and a media type. + +Every entry point that touches media like uploading, probing, and displaying funnels +through `resolve_media` so they all agree on what a file is. A path, raw `bytes`, and +an open binary file are treated alike. + +Detection tries the filename first and magic bytes second. An extension is cheap and +usually right, while the bytes are authoritative when there is no name to go on. +Anything the API would not accept is rejected here rather than at upload time. +""" + +from __future__ import annotations + +import mimetypes +import os +from pathlib import Path +from typing import IO, Optional, Tuple, Union + +from .exceptions import UnsupportedMediaTypeError +from .models import MediaType + +__all__ = ["MediaSource", "SUPPORTED_MEDIA_TYPES", "resolve_media"] + +#: Every MIME type the backend accepts. This is derived from the enum so there is one +#: source of truth. +SUPPORTED_MEDIA_TYPES = frozenset(item.value for item in MediaType) + +#: A type alias for anything `resolve_media` knows how to turn into bytes. +MediaSource = Union[str, "os.PathLike[str]", bytes, bytearray, IO[bytes]] + +#: Magic-byte prefixes checked when there is no filename to go on. These are ordered +#: longest-first where prefixes would otherwise collide. +_MAGIC_PREFIXES: Tuple[Tuple[bytes, MediaType], ...] = ( + (b"\xff\xd8\xff", MediaType.JPEG), + (b"\x89PNG\r\n\x1a\n", MediaType.PNG), + (b"GIF87a", MediaType.GIF), + (b"GIF89a", MediaType.GIF), + (b"\x1a\x45\xdf\xa3", MediaType.WEBM), +) + +#: HEIC and the MP4 family share the ISO-BMFF container. They are told apart by the +#: major brand that follows the "ftyp" box at offset 4. +_FTYP_BRANDS: Tuple[Tuple[bytes, MediaType], ...] = ( + (b"heic", MediaType.HEIC), + (b"heix", MediaType.HEIC), + (b"heif", MediaType.HEIC), + (b"mif1", MediaType.HEIC), + (b"qt ", MediaType.QUICKTIME), +) + +#: Extensions to fall back on when a media type has no `mimetypes` entry. The +#: `mimetypes` module does not know these on every platform, so we pin the ones we care +#: about. +_EXTENSION_OVERRIDES = { + ".heic": MediaType.HEIC, + ".heif": MediaType.HEIC, + ".webp": MediaType.WEBP, + ".webm": MediaType.WEBM, + ".mov": MediaType.QUICKTIME, +} + + +def _coerce(mime: Optional[str], *, hint: str) -> MediaType: + """ + Turn a MIME string into a `MediaType` or explain why it cannot be. + + Args: + mime: The detected MIME type, or `None` when detection found nothing. + hint: How to refer to the media in an error message. + + Returns: + The matching media type enum member. + + Raises: + UnsupportedMediaTypeError: If detection failed or the type is one the API does + not accept. The message lists what is supported. + """ + if mime is None: + raise UnsupportedMediaTypeError( + f"Could not determine the media type of {hint}. " + f"Pass media_type= explicitly. Supported: {sorted(SUPPORTED_MEDIA_TYPES)}" + ) + try: + return MediaType(mime) + except ValueError as exc: + raise UnsupportedMediaTypeError( + f"Unsupported media type {mime!r} for {hint}. " + f"Supported: {sorted(SUPPORTED_MEDIA_TYPES)}" + ) from exc + + +def _sniff(data: bytes) -> Optional[str]: + """ + Guess a MIME type from magic bytes. + + This is used when there is no filename to go on, and it serves as the authority when + the filename provides an incorrect extension. + + Args: + data: The start of the file. Twelve bytes is enough for every format evaluated + here. + + Returns: + The detected MIME type, or `None` when nothing matches. + + Note: + HEIC and the MP4 family share the ISO-BMFF container, so they are told apart by + the brand following the `ftyp` box rather than by a fixed prefix. + """ + for prefix, media_type in _MAGIC_PREFIXES: + if data.startswith(prefix): + return media_type.value + + if len(data) >= 12: + if data[4:8] == b"ftyp": + brand = data[8:12] + for candidate, media_type in _FTYP_BRANDS: + if brand == candidate: + return media_type.value + # every other ISO-BMFF brand (isom, mp42, avc1, ...) is an MP4 + return MediaType.MP4.value + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return MediaType.WEBP.value + + return None + + +def _from_filename(name: str) -> Optional[str]: + """ + Guess a MIME type from a filename while honoring extension overrides. + + Args: + name: A filename or path. Only the suffix is consulted. + + Returns: + The detected MIME type, or `None` when the extension is unknown. + + Note: + A handful of extensions are pinned rather than left to `mimetypes` because the + standard module does not know `.heic` or `.webp` on every platform. + """ + suffix = Path(name).suffix.lower() + if suffix in _EXTENSION_OVERRIDES: + return _EXTENSION_OVERRIDES[suffix].value + guessed, _ = mimetypes.guess_type(name) + return guessed + + +def resolve_media( + source: MediaSource, + *, + media_type: Optional[Union[MediaType, str]] = None, + filename: Optional[str] = None, +) -> Tuple[bytes, MediaType, str]: + """ + Read `source` into bytes and determine its media type and filename. + + This function accepts a filesystem path, raw bytes, or an open binary file object. + An explicit `media_type` short-circuits detection. Otherwise, the filename is tried + first and magic-byte sniffing second. + + Args: + source: The input media as a path, raw bytes, or an open file object. + media_type: An optional explicit media type to skip detection. + filename: An optional explicit filename to use for detection and naming. + + Returns: + A tuple containing the raw data bytes, the resolved `MediaType`, and the + filename. + + Raises: + UnsupportedMediaTypeError: If the type could not be determined or is not + accepted. + FileNotFoundError: If `source` is a path that does not exist. + """ + name = filename + if isinstance(source, (bytes, bytearray)): + data = bytes(source) + hint = "the provided bytes" + elif hasattr(source, "read"): + stream: IO[bytes] = source # type: ignore[assignment] + data = stream.read() + if not isinstance(data, bytes): + raise UnsupportedMediaTypeError( + "File object must be opened in binary mode (e.g. open(path, 'rb'))" + ) + # `.name` is the full path on a real file handle; only the basename belongs + # in the multipart filename + stream_name = getattr(stream, "name", None) + if name is None and isinstance(stream_name, str) and stream_name: + name = Path(stream_name).name + hint = f"file object {name!r}" if name else "the provided file object" + else: + path = Path(os.fspath(source)) + data = path.read_bytes() + name = name or path.name + hint = str(path) + + if not data: + raise UnsupportedMediaTypeError(f"{hint} is empty") + + if media_type is not None: + resolved = _coerce( + media_type.value if isinstance(media_type, MediaType) else media_type, + hint=hint, + ) + else: + guessed = (_from_filename(name) if name else None) or _sniff(data) + resolved = _coerce(guessed, hint=hint) + + if not name: + name = f"upload{mimetypes.guess_extension(resolved.value) or ''}" + + return data, resolved, name diff --git a/src/guard_client/models.py b/src/guard_client/models.py index 18e7cb0..8b2931c 100644 --- a/src/guard_client/models.py +++ b/src/guard_client/models.py @@ -1 +1,1120 @@ -# TODO: Data classes or Pydantic models for responses/requests \ No newline at end of file +""" +Typed models for the Elhio Guard API. + +These models mirror the server's OpenAPI schemas (`/api/v1/openapi.json`). Every model +ignores unknown fields so that additive backend changes never break the client. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import Dict, Generic, Iterator, List, Optional, Type, TypeVar, Union +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from .exceptions import GuardError + +__all__ = [ + "FILTERABLE_RUNNER_STATUSES", + "Activity", + "ActivityCreateResponse", + "ActivityDetail", + "ActivityOrder", + "ActivityPage", + "ActivityResult", + "ActivityResultItem", + "ActivityStatus", + "ActivityStatusResponse", + "DetectionMatch", + "DetectionResult", + "Engine", + "MediaCategory", + "MediaType", + "Page", + "Predictor", + "PredictorOrder", + "PredictorPage", + "PredictorStatus", + "PresignedUploadData", + "Reaction", + "ResultSource", + "Runner", + "RunnerOrder", + "RunnerPage", + "RunnerStatus", + "Share", + "ShareOrder", + "SharePage", + "ShareStatus", + "SortOrder", + "Space", + "SpaceDetail", + "SpaceOrder", + "SpacePage", + "SpaceStatus", + "SpaceThresholds", + "Task", + "TaskOrder", + "TaskPage", + "TaskStatus", + "activity_id_of", + "coerce_enum", + "ensure_bool", + "result_items_of", +] + + +class _Base(BaseModel): + """ + Shared configuration for every response model. + + Unknown fields are ignored rather than rejected. This ensures a backend that starts + sending a new key does not break a client released before that key existed. + """ + + model_config = ConfigDict(extra="ignore") + + +class ActivityStatus(str, Enum): + """ + Lifecycle status of an activity. + + An activity advances from `PENDING_UPLOAD` through `PROCESSING` to one of three + terminal states. Only `COMPLETED` carries results. + + Attributes: + PENDING_UPLOAD: Created and awaiting the media bytes. + PROCESSING: Media received and detection is running. + COMPLETED: Finished successfully. The `result_payload` is populated. + FAILED: Processing failed. + CANCELED: Stopped before completion. + """ + + PENDING_UPLOAD = "pending_upload" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + CANCELED = "canceled" + + @property + def is_terminal(self) -> bool: + """ + Whether the activity has stopped moving. + + Returns: + `True` for `COMPLETED`, `FAILED`, and `CANCELED`. Polling stops on these + states. + """ + return self in _TERMINAL_STATUSES + + +#: A set containing the terminal activity statuses. +_TERMINAL_STATUSES = frozenset( + {ActivityStatus.COMPLETED, ActivityStatus.FAILED, ActivityStatus.CANCELED} +) + + +class MediaType(str, Enum): + """ + Media MIME types the backend accepts. + + Anything outside this set is rejected locally before an upload is attempted. + + Attributes: + JPEG: `image/jpeg`. + PNG: `image/png`. + WEBP: `image/webp`. + GIF: `image/gif`. + HEIC: `image/heic`. Accepted by the API but not renderable by most browsers. + MP4: `video/mp4`. + WEBM: `video/webm`. + QUICKTIME: `video/quicktime`. Accepted but poorly supported by browsers. + """ + + JPEG = "image/jpeg" + PNG = "image/png" + WEBP = "image/webp" + GIF = "image/gif" + HEIC = "image/heic" + MP4 = "video/mp4" + WEBM = "video/webm" + QUICKTIME = "video/quicktime" + + +class Engine(str, Enum): + """ + Which detection engine produced a result. + + Attributes: + CLOUD: The Elhio API. Creates an activity so the result has an `activity_id`. + LOCAL: The optional on-device engine. Creates nothing server-side so + the result has no `activity_id` and cannot be shared or reacted to. + """ + + CLOUD = "cloud" + LOCAL = "local" + + +class SortOrder(str, Enum): + """ + Direction for a `sort_by` field. + + Shared by every list endpoint, though their defaults differ. Spaces sort ascending, + while activities and shares sort descending. + + Attributes: + ASC: Ascending. + DESC: Descending. + """ + + ASC = "asc" + DESC = "desc" + + +class SpaceOrder(str, Enum): + """ + Fields activities can be sorted by. + + Attributes: + NAME: Alphabetical by space name. + CREATED_AT: By creation time. This is the server default. + """ + + NAME = "name" + CREATED_AT = "created_at" + + +class ActivityOrder(str, Enum): + """ + Fields activities can be sorted by. + + Attributes: + CREATED_AT: By creation time. This is the only option and the server default. + """ + + CREATED_AT = "created_at" + + +class PredictorOrder(str, Enum): + """ + Fields predictors can be sorted by. + + Attributes: + NAME: Alphabetical by predictor name. This is the server default. + CREATED_AT: By creation time. + """ + + NAME = "name" + CREATED_AT = "created_at" + + +class TaskOrder(str, Enum): + """ + Fields tasks can be sorted by. + + Attributes: + NAME: Alphabetical by task name. This is the server default. + CREATED_AT: By creation time. + """ + + NAME = "name" + CREATED_AT = "created_at" + + +class SpaceStatus(str, Enum): + """ + Lifecycle status of a space as exposed publicly. + + Attributes: + ACTIVE: Available for use. + """ + + ACTIVE = "active" + + +class PredictorStatus(str, Enum): + """ + Lifecycle status of a predictor as exposed publicly. + + Attributes: + ACTIVE: Available for use. + """ + + ACTIVE = "active" + + +class TaskStatus(str, Enum): + """ + Lifecycle status of a task as exposed publicly. + + Attributes: + ACTIVE: Available for use. + """ + + ACTIVE = "active" + + +class RunnerStatus(str, Enum): + """ + Lifecycle status of a runner. + + All five values can appear on a response but only four may be used as a filter. + Review `FILTERABLE_RUNNER_STATUSES` for details. + + Attributes: + PENDING: Created and the deployment is starting. + RUNNING: Serving requests. + DRAINING: Shutting down and finishing what it has. + FAILED: Creation or teardown failed. + TERMINATED: Gone. Returned on a response but not accepted as a filter. + """ + + PENDING = "pending" + RUNNING = "running" + DRAINING = "draining" + FAILED = "failed" + TERMINATED = "terminated" + + +#: The subset of `RunnerStatus` the `statuses` filter accepts. The API models responses +#: on the full enum but filters on a narrower one, so a runner may come back with a +#: status you cannot search for. +FILTERABLE_RUNNER_STATUSES = frozenset( + { + RunnerStatus.PENDING, + RunnerStatus.RUNNING, + RunnerStatus.DRAINING, + RunnerStatus.FAILED, + } +) + + +class RunnerOrder(str, Enum): + """ + Fields runners can be sorted by. + + Attributes: + NAME: Alphabetical by runner name. + CREATED_AT: By creation time. This is the server default. + """ + + NAME = "name" + CREATED_AT = "created_at" + + +class ShareStatus(str, Enum): + """ + Lifecycle status of an activity share. + + Filter-only. The API accepts these on `statuses` but returns no status field, so a + fetched `Share` reports expiry through `Share.is_expired` instead. + + Attributes: + ACTIVE: The link still works. + EXPIRED: Past its `expired_at` deadline. + """ + + ACTIVE = "active" + EXPIRED = "expired" + + +class ShareOrder(str, Enum): + """ + Fields activity shares can be sorted by. + + Attributes: + CREATED_AT: By creation time. This is the only option and the server default. + """ + + CREATED_AT = "created_at" + + +class MediaCategory(str, Enum): + """ + High-level media category a space accepts. + + Coarser than `MediaType`. A space enables `image` or `video` rather than individual + MIME types. + + Attributes: + IMAGE: Still images. + VIDEO: Moving media. + """ + + IMAGE = "image" + VIDEO = "video" + + +#: A type variable used for enum coercion. +E = TypeVar("E", bound=Enum) + + +def coerce_enum(value: Union[E, str], enum_cls: Type[E], *, field: str) -> E: + """ + Turn a loose string into an enum member or explain what was expected. + + Callers pass plain strings like `sort_by="name"`. This validates them at the + boundary so a typo fails locally with the valid options listed rather than returning + a 422 from the server. + + Args: + value: The string or enum member to coerce. + enum_cls: The enum class to coerce the value into. + field: The field name for the error message. + + Returns: + The matched enum member. + + Raises: + GuardError: If `value` is not one of the `enum_cls` members. + """ + if isinstance(value, enum_cls): + return value + try: + return enum_cls(value) + except ValueError as exc: + valid = ", ".join(repr(member.value) for member in enum_cls) + raise GuardError( + f"Invalid {field}={value!r}. Expected one of: {valid}" + ) from exc + + +def ensure_bool(value: object, *, field: str) -> bool: + """ + Require a real `bool` and reject stand-ins like `1` or `"true"`. + + Boolean filters take only `True` or `False`. Strings and integers are refused rather + than guessed at. This ensures `is_public="false"` fails loudly instead of being read + as a truthy string. + + Note that `bool` subclasses `int`, so this check has to come before any integer + handling. + + Args: + value: The object to check. + field: The field name for the error message. + + Returns: + The boolean value if valid. + + Raises: + GuardError: If `value` is not `True` or `False`. + """ + if isinstance(value, bool): + return value + raise GuardError(f"Invalid {field}={value!r}. Expected a boolean: True or False") + + +class PresignedUploadData(_Base): + """ + S3 presigned POST descriptor returned when an activity is created. + + Attributes: + url: Where to POST the media. This is not the API host and is not authenticated + with your API key. + fields: Policy fields that must appear in the multipart body before the file + part, otherwise storage rejects the upload. + """ + + url: str + fields: Dict[str, str] + + +class DetectionMatch(_Base): + """ + One piece of evidence behind a score. + + The local engine weighs three independent sources against each other. These are + signed C2PA provenance, embedded metadata, and a vision model. It reports what each + of them found. A match is one such finding. It is not "this scored 73" but "the + C2PA manifest names a generative tool", accompanied by the assertion that says so. + + Attributes: + id: Stable identifier for the underlying rule, unique across all three sources. + This is safe to branch on. + category: Which category the rule argues about in the vocabulary of the engine + (`aiGenerated`, `violent`, `explicit`). + label: Short human-readable name of the rule. + description: What the rule means and why it points the way it does. + confidence: 0-100 indicating how strongly this evidence alone implies the + category. + kind: Direction of the evidence. `"authentic"` and `"safe"` argue against the + category, while anything else argues for it. + evidence: The specific tag, assertion, or score found in the media. + source: Which layer reported it. `c2pa`, `metadata`, or `model`. + """ + + id: str + category: str + label: str + description: Optional[str] = None + confidence: int = Field(ge=0, le=100, description="Confidence between 0-100") + kind: Optional[str] = None + evidence: Optional[str] = None + source: Optional[str] = None + + +class ActivityResultItem(_Base): + """ + One task verdict on a piece of media. + + An activity produces one of these per enabled task. The `label` and `description` + arrive already resolved to the client locale. + + Attributes: + task_id: Which detection produced this. Match it against `Task` ids. + score: 0-100 where higher means more strongly detected. This is not a percentage + probability. + label: Human-readable name of the detection in the request locale. + description: Longer explanation in the request locale when one exists. + media_key: Storage key for the solution image. This is internal. `media_url` is + the fetchable form. + media_url: Public URL of the solution image, or `None` when this task produced + no image. It is unauthenticated so `show` and `save` need no credentials. + detected: The engine's threshold verdict, which knows a per-category cutoff no + caller could infer from `score` alone. This is `None` from the cloud API + because it does not report one. + matches: The evidence behind the `score`. `None` from the cloud API because it + returns a score without its reasons. An empty list means the local engine + looked and found nothing. + + Note: + `detected` and `matches` are the one place the two engines differ. They are not + local-only by design. The models ignore unknown fields, so both fill in + automatically if the API starts returning them. + """ + + task_id: UUID + score: int = Field(ge=0, le=100, description="Score between 0-100") + label: str + description: Optional[str] = None + media_key: Optional[str] = None + media_url: Optional[str] = None + detected: Optional[bool] = None + matches: Optional[List[DetectionMatch]] = None + + +class ActivityResult(_Base): + """ + The result payload of a completed activity. + + Attributes: + results: One entry per task the space had enabled. This is empty until + processing finishes. + """ + + results: List[ActivityResultItem] = Field(default_factory=list) + + +class _ActivityOwner(_Base): + """ + The mutually-exclusive owner identifiers carried by most activity models. + + Exactly one is set to identify who the activity belongs to. This determines who may + react to or share it. + + Attributes: + user_id: Owning user when a user token created it. + account_id: Owning service account when a service account token created it. + guest_id: Owning guest for anonymous flows in a public space. This is + read-only here. This client always authenticates and the API assigns + a guest owner only to activities created without an identity, such + as by the browser extension. It is reported so those activities still + round-trip, but is never set by us. + """ + + user_id: Optional[UUID] = None + account_id: Optional[UUID] = None + guest_id: Optional[UUID] = None + + +class Activity(_ActivityOwner): + """ + An analysis run as it appears in listings. + + This is the summary shape. `ActivityDetail` adds the results and processing + metadata. + + Attributes: + id: Server-assigned identifier. + status: Where the run has got to. + created_at: When it was created. + space_id: The space it belongs to. + space_name: That space display name. + user_name: Owning user display name when a user owns it. + account_name: Owning service account name when one owns it. + media_type: MIME type of the submitted media. + """ + + id: UUID + status: ActivityStatus + created_at: datetime + space_id: UUID + space_name: Optional[str] = None + user_name: Optional[str] = None + account_name: Optional[str] = None + media_type: MediaType + + +class ActivityCreateResponse(Activity): + """ + An activity plus the one-time target for uploading its media. + + This is returned only from creation. Fetching the activity again will not give you + `upload_data` a second time, so upload before discarding it. + + Attributes: + upload_data: Where and how to POST the bytes. + """ + + upload_data: PresignedUploadData + + +class ActivityStatusResponse(_ActivityOwner): + """ + Just enough of an activity to poll it. + + This is deliberately small. Polling fetches this rather than the full detail so + waiting does not repeatedly transfer its results. + + Attributes: + id: The activity being polled. + status: Where the run has got to. + """ + + id: UUID + status: ActivityStatus + + +class ActivityDetail(_ActivityOwner): + """ + An activity with everything the API knows about it. + + This is what `activities.get()` returns and the shape carrying the results. + + Attributes: + id: Server-assigned identifier. + status: Where the run has got to. + created_at: When it was created. + updated_at: When it last changed status. + space_id: The space it belongs to. + predictor_id: The model that processed it. + runner_id: The runner that served it when a dedicated one did. + media_type: MIME type of the submitted media. + media_size: Size of the submitted media in bytes. + payed_tokens: What it actually cost. This is the authoritative figure against + which `TokenEstimate` is only an estimate. It remains `None` until + processing ends. + result_payload: The scored results, or `None` while still processing. + space_name: The space display name. + predictor_name: The predictor display name. + runner_name: The runner name when a dedicated one served it. + user_name: Owning user display name. + account_name: Owning service account name. + """ + + id: UUID + status: ActivityStatus + created_at: datetime + updated_at: datetime + space_id: Optional[UUID] = None + predictor_id: Optional[UUID] = None + runner_id: Optional[UUID] = None + media_type: MediaType + media_size: int + payed_tokens: Optional[int] = None + result_payload: Optional[ActivityResult] = None + space_name: Optional[str] = None + predictor_name: Optional[str] = None + runner_name: Optional[str] = None + user_name: Optional[str] = None + account_name: Optional[str] = None + + +class Space(_Base): + """ + A container that activities are created in as it appears in listings. + + A space fixes which predictor runs, which tasks are enabled, and who owns the + results. `SpaceDetail` is the fuller shape from `spaces.get()`. + + Attributes: + id: Server-assigned identifier. This is what `GUARD_SPACE_ID` holds. + status: Lifecycle status. Only `ACTIVE` is ever returned. + created_at: When the space was created. + name: Display name consisting of 3-50 characters. + description: Longer description when one was set. + slug: URL-safe form of the name. + url_id: Short public identifier used in web links. + is_default: Whether it is shown as the default space of the owner. + is_public: Whether everyone can see it. + user_id: Owning user when a person owns it. + user_name: That user display name. + organization_id: Owning organization when one owns it. + organization_name: That organization display name. + predictor_id: The model this space runs. + predictor_name: That predictor display name. + enabled_media: Which media categories may be submitted. + enabled_task_names: Names of the enabled detections. The detail endpoint + returns full task objects under `enabled_tasks` instead. + """ + + id: UUID + status: SpaceStatus + created_at: datetime + name: str + description: Optional[str] = None + slug: str + url_id: str + is_default: bool + is_public: bool + user_id: Optional[UUID] = None + user_name: Optional[str] = None + organization_id: Optional[UUID] = None + organization_name: Optional[str] = None + predictor_id: UUID + predictor_name: Optional[str] = None + enabled_media: List[MediaCategory] = Field(default_factory=list) + enabled_task_names: List[str] = Field(default_factory=list) + + @property + def owner_name(self) -> Optional[str]: + """ + Display name of whoever owns this space. + + Returns: + The organization name when an organization owns it, otherwise the user name. + Returns `None` if the server sent neither. + """ + return self.organization_name or self.user_name + + +class Predictor(_Base): + """ + A detection model that powers a space. + + Its id is required to create a space, and its `token_multiplier` is what + `SpaceDetail.predictor_multiplier` mirrors. + + Attributes: + id: Server-assigned identifier used for `spaces.create(predictor_id=...)`. + name: Display name. + status: Lifecycle status. Only `ACTIVE` is ever returned. + description: What the model does. + token_multiplier: Cost multiplier applied to every activity in a space using it. + slug: URL-safe form of the name. + url_id: Short public identifier used in web links. + supported_media: Which media categories it can process. + supported_task_ids: Which detections it can run. + """ + + id: UUID + name: str + status: PredictorStatus + description: str + token_multiplier: int + slug: str + url_id: str + supported_media: List[MediaCategory] = Field(default_factory=list) + supported_task_ids: List[UUID] = Field(default_factory=list) + + +class Task(_Base): + """ + One detection a space can enable, such as deepfake or violence. + + Attributes: + id: Server-assigned identifier used for `spaces.create(enabled_task_ids=...)`. + status: Lifecycle status. Only `ACTIVE` is ever returned. + name: Display name in the request locale. + description: What the detection looks for in the request locale. + reactions: Expected-result options keyed by the integer a reaction sends as + `key_value`. For example `{1: "Real photo", 2: "AI generated"}`. + """ + + id: UUID + status: TaskStatus + name: str + description: Optional[str] = None + reactions: Dict[int, str] = Field( + default_factory=dict, + description=( + "Expected-result options, e.g. {1: 'Real photo', 2: 'AI generated'}. " + "The keys are the valid key_value choices for Reactions.create." + ), + ) + + +class Reaction(_Base): + """ + Feedback on whether one task result was correct. + + The API accepts one reaction per activity and task, and offers no way for a + non-admin to read or remove one afterwards. This is only ever seen as the return + value of creating it. + + Attributes: + id: Server-assigned identifier. + created_at: When the feedback was recorded. + activity_id: The activity being commented on. + task_id: Which task result the feedback concerns. + is_positive: `True` if the detection was right. + key_value: The expected result as a key of that task `reactions` map. + description: Free-text comment up to 255 characters. + """ + + id: UUID + created_at: datetime + activity_id: UUID + task_id: UUID + is_positive: bool + key_value: Optional[int] = Field( + default=None, + description="The expected result, keyed into the task's `reactions` map", + ) + description: Optional[str] = None + + +class SpaceThresholds(_Base): + """ + Per-task score cut-offs configured on a space. + + Attributes: + task_id: Which detection these thresholds apply to. + blur_threshold: Score at or above which media is blurred, 0-100. + hide_threshold: Score at or above which media is hidden entirely, 0-100. + """ + + task_id: UUID + blur_threshold: Optional[int] = Field(default=None, ge=0, le=100) + hide_threshold: Optional[int] = Field(default=None, ge=0, le=100) + + +class SpaceDetail(_Base): + """ + A space with its full configuration. + + This is deliberately not a subclass of `Space`. The detail endpoint returns + `enabled_tasks` as full task objects, whereas the list endpoint returns + `enabled_task_names` as plain strings. The two shapes genuinely diverge. + + Attributes: + id: Server-assigned identifier. + status: Lifecycle status. Only `ACTIVE` is ever returned. + created_at: When the space was created. + name: Display name consisting of 3-50 characters. + description: Longer description when one was set. + slug: URL-safe form of the name. + url_id: Short public identifier used in web links. + is_default: Whether it is shown as the default space of the owner. + is_public: Whether everyone can see it. + user_id: Owning user when a person owns it. + user_name: That user display name. + organization_id: Owning organization when one owns it. + organization_name: That organization display name. + predictor_id: The model this space runs. + predictor_name: That predictor display name. + predictor_multiplier: Token cost multiplier. Mirrors the predictor + `token_multiplier` and is what `GuardClient.estimate_tokens` reads. + max_media_size: Largest accepted upload in bytes when the space caps it. + enabled_media: Which media categories may be submitted. + enabled_tasks: The enabled detections as full objects rather than names. + task_thresholds: Per-task blur and hide cut-offs. + """ + + id: UUID + status: SpaceStatus + created_at: datetime + name: str + description: Optional[str] = None + slug: str + url_id: str + is_default: bool + is_public: bool + user_id: Optional[UUID] = None + user_name: Optional[str] = None + organization_id: Optional[UUID] = None + organization_name: Optional[str] = None + predictor_id: UUID + predictor_name: Optional[str] = None + predictor_multiplier: Optional[int] = Field( + default=None, + description="Token cost multiplier; mirrors the predictor's token_multiplier", + ) + max_media_size: Optional[int] = Field(default=None, description="Bytes") + enabled_media: List[MediaCategory] = Field(default_factory=list) + enabled_tasks: List[Task] = Field(default_factory=list) + task_thresholds: List[SpaceThresholds] = Field(default_factory=list) + + @property + def owner_name(self) -> Optional[str]: + """ + Display name of whoever owns this space. + + Returns: + The organization name when an organization owns it, otherwise the user name. + Returns `None` if the server sent neither. + """ + return self.organization_name or self.user_name + + +class Share(_Base): + """ + A public link to one task result for an activity. + + The API allows one share per activity and offers no way to revoke it. A link lives + until `expired_at` passes. + + Attributes: + id: Server-assigned identifier. + created_at: When the link was created. + expired_at: When it stops working. See `is_expired`. + activity_id: The activity being shared. + task_id: Which task result the link shows. + task_name: That task display name in the request locale. + space_name: The owning space display name. + expires_in: Requested lifetime in days, 1-7. + share_url: The public link to hand out. This is the point of the object. + media_url: Direct URL of the shared media when there is one. + result: The scored result the link displays. + """ + + id: UUID + created_at: datetime + expired_at: datetime + activity_id: UUID + task_id: UUID + task_name: Optional[str] = None + space_name: Optional[str] = None + expires_in: int = Field(default=7, description="Lifetime in days, 1-7") + share_url: str + media_url: Optional[str] = None + result: Optional[ActivityResultItem] = None + + @property + def is_expired(self) -> bool: + """ + Whether the link has lapsed. + + Derived from `expired_at` because the API returns no status field even though it + accepts `active` or `expired` as a filter. + + Returns: + `True` once the expiry has passed. A naive `expired_at` is read as UTC, + matching the server. + """ + deadline = self.expired_at + if deadline.tzinfo is None: + # match the server, which works in UTC + deadline = deadline.replace(tzinfo=timezone.utc) + return deadline <= datetime.now(timezone.utc) + + +class Runner(_Base): + """ + A dedicated compute instance serving one predictor for an organization. + + Attributes: + id: Server-assigned identifier. + status: Where the deployment has got to. May be `TERMINATED`, which cannot be + used as a filter. + created_at: When the runner was created. + terminated_at: When it was torn down, or `None` while it lives. + name: Display name. + slug: URL-safe form of the name. + url_id: Short public identifier used in web links. + predictor_id: The model this runner serves. + predictor_name: That predictor display name. + organization_id: The owning organization. Runners are always org-scoped. + organization_name: That organization display name. + """ + + id: UUID + status: RunnerStatus + created_at: datetime + terminated_at: Optional[datetime] = None + name: str + slug: str + url_id: str + predictor_id: UUID + predictor_name: Optional[str] = None + organization_id: UUID + organization_name: Optional[str] = None + + +#: A type variable for elements in a page. +ItemT = TypeVar("ItemT") + + +class Page(BaseModel, Generic[ItemT]): + """ + One page of results plus the total number matching the filter. + + This behaves like a list. You can iterate it, index it, and check its `len()`, while + keeping `count` available so callers can tell whether more pages exist. + + `len(page)` is the size of this particular page. `page.count` is the total across + all pages. Note that iterating yields items, so `dict(page)` does not produce field + pairs. Use `model_dump()` to serialize it. + """ + + model_config = ConfigDict(extra="ignore") + + data: List[ItemT] = Field(default_factory=list) + count: int = 0 + + def __iter__(self) -> Iterator[ItemT]: # type: ignore[override] + """ + Iterate the items on this page. + + Yields: + Each item in `data` in the order the server returned them. + + Note: + This overrides the default `__iter__` behavior of pydantic so `dict(page)` + yields items rather than field pairs. Use `model_dump()` to serialize. + """ + return iter(self.data) + + def __len__(self) -> int: + """ + Report how many items this page holds. + + Returns: + The size of this page, not the total. `count` is the total. + """ + return len(self.data) + + def __getitem__(self, index: int) -> ItemT: + """ + Index into the page items. + + Args: + index: Position within this page. + + Returns: + The item at that position. + """ + return self.data[index] + + def __bool__(self) -> bool: + """ + Report whether this page holds anything. + + Returns: + `False` for an empty page so `if page:` reads naturally. + """ + return bool(self.data) + + @property + def has_more(self) -> bool: + """ + True when `count` exceeds what this page holds. + + This is only meaningful for a first page where `skip=0`. With a non-zero skip, + you should compare `skip + len(page)` against `count` yourself. + """ + return len(self.data) < self.count + + +#: One page of spaces as returned by `Spaces.list`. +SpacePage = Page[Space] +#: One page of activities as returned by `Activities.list`. +ActivityPage = Page[Activity] +#: One page of predictors as returned by `Predictors.list`. +PredictorPage = Page[Predictor] +#: One page of tasks as returned by `Tasks.list`. +TaskPage = Page[Task] +#: One page of runners as returned by `Runners.list`. +RunnerPage = Page[Runner] +#: One page of shares as returned by `Shares.list`. +SharePage = Page[Share] + + +class DetectionResult(_Base): + """ + The unified return type of `GuardClient.analyze`. + + This is identical in shape whether the cloud API or the local engine produced it. + This ensures callers can switch engines without touching the code that reads + results. The local engine additionally fills in each item's `detected` and `matches` + fields which the cloud API leaves unset. Reading them is opting into extra detail, + not into a different shape. + """ + + engine: Engine + results: List[ActivityResultItem] = Field(default_factory=list) + activity_id: Optional[UUID] = Field( + default=None, description="None for local runs, which create no activity" + ) + + @property + def max_score(self) -> int: + """ + The strongest detection across every task. + + This is useful as a single number to threshold on when the specific task matters + less than whether anything fired. + + Returns: + The highest score in `results` or 0 when there are none. + """ + return max((item.score for item in self.results), default=0) + + def score_for(self, task_id: UUID) -> Optional[int]: + """ + Look up one task score. + + Args: + task_id: Which detection to read. + + Returns: + That task score, or `None` when the task is absent from the results. This is + different from a score of 0. + """ + return next( + (item.score for item in self.results if item.task_id == task_id), None + ) + + +#: A type alias for anything carrying the scored results of an activity, whichever call +#: produced it. `GuardClient.analyze` returns a `DetectionResult` and `activities.get()` +#: returns an `ActivityDetail`. They spell the same two things differently, which the +#: helper functions below reconcile. +ResultSource = Union[DetectionResult, ActivityDetail] + + +def activity_id_of(source: ResultSource) -> Optional[UUID]: + """ + Extract the activity id behind a result. + + Args: + source: The result source to examine. + + Returns: + The activity id behind the result, or `None` when there is no server-side + activity. `None` means it is a local-engine `DetectionResult`. Nothing was + created on the server, so there is nothing to react to or share. + """ + if isinstance(source, DetectionResult): + return source.activity_id + return source.id + + +def result_items_of(source: ResultSource) -> List[ActivityResultItem]: + """ + Pull the scored items out of either result shape. + + Args: + source: A `DetectionResult` from `analyze()` or an `ActivityDetail` from + `activities.get()`. + + Returns: + The result items. This is empty when the activity is still processing because + `result_payload` is only populated once it completes. + """ + if isinstance(source, DetectionResult): + return list(source.results) + return list(source.result_payload.results) if source.result_payload else [] diff --git a/src/guard_client/predictors.py b/src/guard_client/predictors.py new file mode 100644 index 0000000..fcfb9d7 --- /dev/null +++ b/src/guard_client/predictors.py @@ -0,0 +1,273 @@ +""" +Low-level bindings for the `/api/v1/predictors/` endpoints. + +A predictor is the model that powers a space, and its ID is required to create one. +Listing predictors is how a caller finds a valid `predictor_id`. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Union + +from .filters import MAX_LIMIT, IdLike, add_ids, add_sort, id_list, validate_pagination +from .models import Predictor, PredictorOrder, PredictorPage, SortOrder +from .transport import AsyncTransport, SyncTransport + +__all__ = ["AsyncPredictors", "Predictors"] + +#: The base URL path for predictor endpoints. +_BASE = "/api/v1/predictors/" + + +class _PredictorsBase: + """ + Query and payload construction with no network I/O. + + Everything that does not touch the network lives here. This ensures the synchronous + and asynchronous resources cannot drift in how they build or validate a request. + """ + + @staticmethod + def _list_params( + *, + user_id: Optional[IdLike], + organization_id: Optional[IdLike], + supported_task_ids: Optional[Sequence[IdLike]], + sort_by: Optional[Union[PredictorOrder, str]], + sort_order: Optional[Union[SortOrder, str]], + skip: int, + limit: int, + ) -> Dict[str, Any]: + """ + Build the query parameters for a list request. + + Validating here means a bad filter never reaches the network. The arguments + mirror `list`, but none are optional here. + + Args: + user_id: Only predictors available to this user. + organization_id: Only predictors available to this organization. + supported_task_ids: Only predictors supporting all of these tasks. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + skip: Offset of the results. Must be 0 or greater. + limit: Page size, 1-100. + + Returns: + The query dict with every unset filter omitted. + + Raises: + GuardError: If a filter value is invalid. + """ + validate_pagination(skip, limit) + + params: Dict[str, Any] = {"skip": skip, "limit": limit} + # unlike spaces, this route does not reject both owner filters together, so no + # mutual-exclusion check is imposed here + add_ids(params, user_id=user_id, organization_id=organization_id) + task_ids = id_list(supported_task_ids, field="supported_task_ids") + if task_ids: + params["supported_task_ids"] = task_ids + add_sort(params, sort_by, sort_order, PredictorOrder) + return params + + +class Predictors(_PredictorsBase): + """ + Synchronous predictor endpoints. + + This class is accessed through the client rather than being constructed directly, + and it shares the client connection pool. + """ + + def __init__(self, transport: SyncTransport) -> None: + """ + Bind this resource to a transport. + + Args: + transport: The client transport whose connection pool is shared. + """ + self._transport = transport + + def list( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + supported_task_ids: Optional[Sequence[IdLike]] = None, + sort_by: Optional[Union[PredictorOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> PredictorPage: + """ + List the available predictors. + + Args: + user_id: Only predictors available to this user. + organization_id: Only predictors available to this organization. + supported_task_ids: Only predictors supporting all of these tasks. + sort_by: Valid options include `"name"` or `"created_at"`. Server default: + `"name"`. + sort_order: `"asc"` or `"desc"`. Server default: `"asc"`. + skip: Offset. 0 or greater. + limit: Page size, 1-100. + + Returns: + A `PredictorPage`. You can iterate it like a list or read `.count`. + """ + params = self._list_params( + user_id=user_id, + organization_id=organization_id, + supported_task_ids=supported_task_ids, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = self._transport.request("GET", _BASE, params=params) + return PredictorPage.model_validate(data) + + def iter_all( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + supported_task_ids: Optional[Sequence[IdLike]] = None, + sort_by: Optional[Union[PredictorOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> Iterator[Predictor]: + """ + Yield every matching predictor by fetching pages as needed. + + Args: + user_id: Only predictors available to this user. + organization_id: Only predictors available to this organization. + supported_task_ids: Only predictors supporting all of these tasks. + sort_by: Valid options include `"name"` or `"created_at"`. + sort_order: `"asc"` or `"desc"`. + page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. + + Yields: + Each matching predictor with the oldest page first. + + Note: + Pages are fetched lazily. Breaking out of the loop early stops the requests + rather than paying for the whole set. + """ + skip = 0 + while True: + page = self.list( + user_id=user_id, + organization_id=organization_id, + supported_task_ids=supported_task_ids, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + yield from page.data + + skip += len(page.data) + if not page.data or len(page.data) < page_size or skip >= page.count: + return + + +class AsyncPredictors(_PredictorsBase): + """ + Asynchronous predictor endpoints. + + This class mirrors `Predictors` method for method. See the synchronous methods for + full argument details. + """ + + def __init__(self, transport: AsyncTransport) -> None: + """ + Bind this resource to a transport. + + Args: + transport: The client transport whose connection pool is shared. + """ + self._transport = transport + + async def list( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + supported_task_ids: Optional[Sequence[IdLike]] = None, + sort_by: Optional[Union[PredictorOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> PredictorPage: + """ + List the predictors matching the given filters. + + Args: + user_id: Only predictors available to this user. + organization_id: Only predictors available to this organization. + supported_task_ids: Only predictors supporting all of these tasks. + sort_by: Valid options include `"name"` or `"created_at"`. + sort_order: `"asc"` or `"desc"`. + skip: Offset. 0 or greater. + limit: Page size, 1-100. + + Returns: + A `PredictorPage`. Review `Predictors.list` for full filter details. + """ + params = self._list_params( + user_id=user_id, + organization_id=organization_id, + supported_task_ids=supported_task_ids, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = await self._transport.request("GET", _BASE, params=params) + return PredictorPage.model_validate(data) + + async def iter_all( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + supported_task_ids: Optional[Sequence[IdLike]] = None, + sort_by: Optional[Union[PredictorOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> AsyncIterator[Predictor]: + """ + Yield every matching predictor by fetching pages as needed. + + Args: + user_id: Only predictors available to this user. + organization_id: Only predictors available to this organization. + supported_task_ids: Only predictors supporting all of these tasks. + sort_by: Valid options include `"name"` or `"created_at"`. + sort_order: `"asc"` or `"desc"`. + page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. + + Yields: + Each matching predictor. Review `Predictors.iter_all` for more + context. + """ + skip = 0 + while True: + page = await self.list( + user_id=user_id, + organization_id=organization_id, + supported_task_ids=supported_task_ids, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + for predictor in page.data: + yield predictor + + skip += len(page.data) + if not page.data or len(page.data) < page_size or skip >= page.count: + return diff --git a/src/guard_client/probe.py b/src/guard_client/probe.py new file mode 100644 index 0000000..eb6738a --- /dev/null +++ b/src/guard_client/probe.py @@ -0,0 +1,608 @@ +""" +Reads dimensions and duration straight out of media headers. + +This is deliberately dependency-free. The client ships with no image or video library, +and adding one just for a cost estimate would be a heavy price. Each parser reads only +the bytes it needs, so probing a large video does not load it into memory. + +Anything that cannot be parsed raises a `GuardError` naming the file rather than +guessing. A wrong dimension here becomes a wrong cost estimate. +""" + +from __future__ import annotations + +import struct +from typing import Iterator, Optional, Tuple + +from pydantic import BaseModel, ConfigDict + +from .exceptions import GuardError +from .media import MediaSource, resolve_media +from .models import MediaType +from .tokens import frames_for + +__all__ = ["MediaInfo", "probe_media"] + +#: How much of a file to read. Metadata lives near the start in every format handled +#: here except MOV files that place `moov` last. That is why the QuickTime parser +#: includes a tail read. +_HEAD_BYTES = 512 * 1024 + +#: Media types that share the ISO-BMFF container format. +_ISO_BMFF_TYPES = frozenset({MediaType.MP4, MediaType.QUICKTIME, MediaType.HEIC}) + + +class MediaInfo(BaseModel): + """ + What a probe could determine about a piece of media. + + Attributes: + media_type: The detected MIME type. + width: Width in pixels. + height: Height in pixels. + duration_seconds: Length of a video. Zero for a still image. + frames: Billable frames, already derived from the duration. + """ + + model_config = ConfigDict(extra="ignore") + + media_type: MediaType + width: int + height: int + duration_seconds: float = 0.0 + frames: int = 1 + + @property + def long_side(self) -> int: + """ + The dimension the resolution tier is based on. + + Returns: + The larger of width and height. This makes the resolution tier and the + resulting cost independent of orientation. + """ + return max(self.width, self.height) + + +def _fail(hint: str, reason: str) -> GuardError: + """ + Build the error raised when a header cannot be read. + + Args: + hint: How to name the media in the message. + reason: What specifically went wrong. + + Returns: + A `GuardError` naming the media and providing the explicit arguments to pass + instead. Guessing a dimension here would become a wrong cost estimate, so the + message points at the escape hatch rather than apologizing. + """ + return GuardError( + f"Could not read the dimensions of {hint}: {reason}. Pass width=, height= and " + f"frames= (or duration_seconds=) explicitly instead." + ) + + +def _png(data: bytes) -> Optional[Tuple[int, int]]: + """ + Read dimensions from a PNG header. + + Args: + data: The start of the file. + + Returns: + A tuple of `(width, height)`, or `None` when the IHDR box is not where it should + be. + """ + # 8-byte signature, then an IHDR box whose payload starts at 16 + if len(data) < 24 or data[12:16] != b"IHDR": + return None + width, height = struct.unpack(">II", data[16:24]) + return width, height + + +def _gif(data: bytes) -> Optional[Tuple[int, int]]: + """ + Read dimensions from a GIF header. + + Args: + data: The start of the file. + + Returns: + A tuple of `(width, height)`, or `None` when the header is truncated. + """ + # logical screen descriptor, little-endian, immediately after the 6-byte header + if len(data) < 10: + return None + width, height = struct.unpack(" Optional[Tuple[int, int]]: + """ + Read dimensions from a JPEG by walking its segment chain. + + Dimensions live in a start-of-frame segment which sits behind a variable number of + metadata segments. The chain has to be followed rather than indexed into directly. + + Args: + data: The start of the file. + + Returns: + A tuple of `(width, height)`, or `None` when no start-of-frame marker was found. + """ + index = 2 # skip SOI + end = len(data) + while index + 9 < end: + if data[index] != 0xFF: + index += 1 # resynchronise on padding + continue + + marker = data[index + 1] + # standalone markers carry no length + if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7: + index += 2 + continue + if marker == 0xFF: + index += 1 + continue + + (length,) = struct.unpack(">H", data[index + 2 : index + 4]) + # SOF0-SOF15, excluding the non-frame markers in that range + if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): + height, width = struct.unpack(">HH", data[index + 5 : index + 9]) + return width, height + if length < 2: + return None + index += 2 + length + return None + + +def _webp(data: bytes) -> Optional[Tuple[int, int]]: + """ + Read dimensions from any of the three WebP variants. + + Args: + data: The start of the file. + + Returns: + A tuple of `(width, height)`, or `None` when the chunk type is unrecognized. + + Note: + Lossy, lossless, and extended WebP store their dimensions differently and at + different offsets. A minimal lossless file is shorter than a lossy one, so each + branch checks its own length rather than sharing one guard. + """ + # only enough to identify the variant; each branch checks what it actually needs, + # since a minimal lossless file is shorter than a lossy one + if len(data) < 16 or data[8:12] != b"WEBP": + return None + chunk = data[12:16] + + if chunk == b"VP8 ": + # lossy: chunk payload starts at 20 with a 3-byte frame tag and 3-byte sync + # code, so the 14-bit dimensions land at 26 + if len(data) < 30: + return None + width, height = struct.unpack("> 14) & 0x3FFF) + 1 + + if chunk == b"VP8X": + # extended: 24-bit canvas dimensions, each stored minus one + if len(data) < 30: + return None + width = int.from_bytes(data[24:27], "little") + 1 + height = int.from_bytes(data[27:30], "little") + 1 + return width, height + + return None + + +def _iso_boxes(data: bytes, start: int, end: int) -> Iterator[Tuple[bytes, int, int]]: + """ + Walk the ISO-BMFF boxes in a byte range. + + A box is defined by a length, a four-character type, and its payload. Sizes of 1 and + 0 are special. A size of 1 means a 64-bit length follows the type. A size of 0 means + the box runs to the end of its container. + + Args: + data: The buffer to read. + start: Where to begin. + end: One past the last byte to consider. + + Yields: + A tuple of `(type, payload_start, payload_end)` per box. It stops early on a + malformed length rather than reading past the buffer. + """ + index = start + while index + 8 <= end: + (size,) = struct.unpack(">I", data[index : index + 4]) + box_type = data[index + 4 : index + 8] + header = 8 + + if size == 1: # 64-bit extended size + if index + 16 > end: + return + (size,) = struct.unpack(">Q", data[index + 8 : index + 16]) + header = 16 + elif size == 0: # extends to the end of the container + size = end - index + + if size < header: + return + yield box_type, index + header, min(index + size, end) + index += size + + +#: Container boxes that are FullBoxes. Their 4-byte version/flags field sits before the +#: child boxes. Missing this is why a naive walk never finds anything inside `meta` +_ISO_FULLBOX_CONTAINERS = frozenset({b"meta"}) + + +def _iso_find_all( + data: bytes, path: Tuple[bytes, ...], start: int, end: int +) -> Iterator[Tuple[int, int]]: + """ + Find every box matching a nested path. + + Args: + data: The buffer to read. + path: Box types to descend through, such as `(b"moov", b"mvhd")`. + start: Where to begin. + end: One past the last byte to consider. + + Yields: + A tuple of `(payload_start, payload_end)` for each match. + + Note: + A HEIC file usually holds several `ispe` boxes containing thumbnails as well as + the primary image, so callers need all of them rather than just the first. + Container boxes that are FullBoxes, such as `meta`, carry four bytes of version + and flags before their children. Missing that offset is why a naive walk finds + nothing inside them. + """ + head, rest = path[0], path[1:] + for box_type, box_start, box_end in _iso_boxes(data, start, end): + if box_type != head: + continue + if not rest: + yield box_start, box_end + else: + child_start, child_end = box_start, box_end + if head in _ISO_FULLBOX_CONTAINERS: + child_start = min(child_start + 4, child_end) + yield from _iso_find_all(data, rest, child_start, child_end) + + +def _iso_find( + data: bytes, path: Tuple[bytes, ...], start: int, end: int +) -> Optional[Tuple[int, int]]: + """ + Find the first box matching a nested path. + + Args: + data: The buffer to read. + path: Box types to descend through, such as `(b"moov", b"mvhd")`. + start: Where to begin. + end: One past the last byte to consider. + + Returns: + A tuple of `(payload_start, payload_end)`, or `None` when the path is absent. + """ + return next(_iso_find_all(data, path, start, end), None) + + +def _iso_duration(data: bytes) -> float: + """ + Read a video duration from its movie header. + + The header stores a tick count and a timescale rather than seconds, so the duration + is their quotient. + + Args: + data: The buffer to search. + + Returns: + Duration in seconds. Returns zero when there is no movie header, such as in a + HEIC still, or when the file declares its duration unknown. + """ + found = _iso_find(data, (b"moov", b"mvhd"), 0, len(data)) + if not found: + return 0.0 + start, end = found + if end - start < 4: + return 0.0 + + version = data[start] + if version == 1: + if end - start < 28: + return 0.0 + timescale, duration = struct.unpack(">IQ", data[start + 20 : start + 32]) + else: + if end - start < 16: + return 0.0 + timescale, duration = struct.unpack(">II", data[start + 12 : start + 20]) + + if not timescale: + return 0.0 + # 0xFFFFFFFF is the "unknown duration" sentinel + if duration in (0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF): + return 0.0 + return float(duration) / float(timescale) + + +def _iso_dimensions(data: bytes) -> Optional[Tuple[int, int]]: + """ + Read display dimensions from a track header or a HEIC spatial extents box. + + Args: + data: The buffer to search. + + Returns: + A tuple of `(width, height)`, or `None` when neither source is present. + + Note: + Video stores dimensions as 16.16 fixed point in `tkhd`. HEIC has no such box and + keeps them in `ispe` instead. There is one per stored image, so thumbnails + appear alongside the real thing and the largest is taken. Resolving the true + primary item would mean following `pitm` through `ipma` associations, and + over-reporting is the safer error for a cost estimate. + """ + found = _iso_find(data, (b"moov", b"trak", b"tkhd"), 0, len(data)) + if found: + start, end = found + version = data[start] + # width/height are the last 8 bytes, as 16.16 fixed point + offset = start + (96 if version == 1 else 84) - 8 + if offset + 8 <= end: + width, height = struct.unpack(">II", data[offset : offset + 8]) + width, height = width >> 16, height >> 16 + if width and height: + return width, height + + # HEIC keeps dimensions in image-spatial-extents boxes instead. There is usually one + # per stored image, so thumbnails appear alongside the real thing; take the largest. + # Resolving the true primary item would mean following `pitm` through `ipma` + # associations, and over-reporting is the safer error for a cost estimate. + largest: Optional[Tuple[int, int]] = None + for start, end in _iso_find_all( + data, (b"meta", b"iprp", b"ipco", b"ispe"), 0, len(data) + ): + if end - start < 12: + continue + width, height = struct.unpack(">II", data[start + 4 : start + 12]) + if ( + width + and height + and (largest is None or width * height > largest[0] * largest[1]) + ): + largest = (width, height) + return largest + + +def _ebml_number(data: bytes, index: int, *, keep_marker: bool) -> Tuple[int, int]: + """ + Read an EBML variable-length integer. + + The leading zero bits encode the width, and the first set bit is a marker that is + part of an element ID but not of a length. + + Args: + data: The buffer to read. + index: Where the number starts. + keep_marker: Set to `True` for element IDs which include the marker bit, and + `False` for lengths where it must be stripped. + + Returns: + A tuple of `(value, next_index)`. + + Raises: + ValueError: If the buffer ends mid-number or the width descriptor is invalid. + """ + if index >= len(data): + raise ValueError("truncated") + first = data[index] + if first == 0: + raise ValueError("invalid length descriptor") + length = 8 - first.bit_length() + 1 + if index + length > len(data): + raise ValueError("truncated") + + value = int.from_bytes(data[index : index + length], "big") + if not keep_marker: + value &= (1 << (7 * length)) - 1 # strip the leading marker bit + return value, index + length + + +def _webm_elements(data: bytes, start: int, end: int) -> Iterator[Tuple[int, int, int]]: + """ + Walk the EBML elements in a byte range. + + Args: + data: The buffer to read. + start: Where to begin. + end: One past the last byte to consider. + + Yields: + A tuple of `(id, payload_start, payload_end)` per element, stopping early on a + malformed number. An element declaring an unknown size runs to the end of its + parent. + """ + index = start + while index < end: + try: + element_id, index = _ebml_number(data, index, keep_marker=True) + size, index = _ebml_number(data, index, keep_marker=False) + except ValueError: + return + # an unknown-size element runs to the end of its parent + stop = end if size >= (1 << 56) - 1 else min(index + size, end) + yield element_id, index, stop + index = stop + + +def _webm_uint(data: bytes, start: int, end: int) -> int: + """ + Read an EBML unsigned integer of any width. + + Args: + data: The buffer to read. + start: First byte of the value. + end: One past the last byte. + + Returns: + The integer value, or 0 for an empty range. + """ + return int.from_bytes(data[start:end], "big") if end > start else 0 + + +def _webm_float(data: bytes, start: int, end: int) -> float: + """ + Read an EBML float. + + Args: + data: The buffer to read. + start: First byte of the value. + end: One past the last byte. + + Returns: + The float value, or 0.0 for a width Matroska does not define. + """ + width = end - start + if width == 4: + return float(struct.unpack(">f", data[start:end])[0]) + if width == 8: + return float(struct.unpack(">d", data[start:end])[0]) + return 0.0 + + +def _webm(data: bytes) -> Optional[Tuple[int, int, float]]: + """ + Read dimensions and duration from a Matroska or WebM header. + + Args: + data: The start of the file. + + Returns: + A tuple of `(width, height, duration_seconds)`, or `None` when no video track + was found. + + Note: + Duration is stored in timecode ticks scaled by `TimecodeScale` nanoseconds. This + defaults to a millisecond when the file omits it. + """ + segment = None + for element_id, start, end in _webm_elements(data, 0, len(data)): + if element_id == 0x18538067: # Segment + segment = (start, end) + break + if segment is None: + return None + + width = height = 0 + timecode_scale = 1_000_000.0 # nanoseconds per tick, the Matroska default + raw_duration = 0.0 + + for element_id, start, end in _webm_elements(data, *segment): + if element_id == 0x1549A966: # Info + for sub_id, sub_start, sub_end in _webm_elements(data, start, end): + if sub_id == 0x2AD7B1: # TimecodeScale + timecode_scale = ( + float(_webm_uint(data, sub_start, sub_end)) or timecode_scale + ) + elif sub_id == 0x4489: # Duration + raw_duration = _webm_float(data, sub_start, sub_end) + elif element_id == 0x1654AE6B: # Tracks + for track_id, track_start, track_end in _webm_elements(data, start, end): + if track_id != 0xAE: # TrackEntry + continue + for field_id, f_start, f_end in _webm_elements( + data, track_start, track_end + ): + if field_id != 0xE0: # Video + continue + for v_id, v_start, v_end in _webm_elements(data, f_start, f_end): + if v_id == 0xB0: # PixelWidth + width = _webm_uint(data, v_start, v_end) + elif v_id == 0xBA: # PixelHeight + height = _webm_uint(data, v_start, v_end) + + if not width or not height: + return None + return width, height, raw_duration * timecode_scale / 1_000_000_000.0 + + +def probe_media( + source: MediaSource, + *, + media_type: Optional[MediaType] = None, + filename: Optional[str] = None, +) -> MediaInfo: + """ + Read dimensions and duration from a file without decoding it. + + Args: + source: A path, raw `bytes`, or an open binary file object. + media_type: Skips MIME detection when you already know the type. + filename: Used for MIME detection and error messages. + + Returns: + A `MediaInfo` object with `frames` already derived from the duration. + + Raises: + GuardError: If the format is unsupported or the header could not be read. The + message lists the explicit arguments to pass instead. + UnsupportedMediaTypeError: If the media type is not one the API accepts. + """ + data, resolved_type, name = resolve_media( + source, media_type=media_type, filename=filename + ) + head = data[:_HEAD_BYTES] + + dimensions: Optional[Tuple[int, int]] = None + duration = 0.0 + + if resolved_type is MediaType.PNG: + dimensions = _png(head) + elif resolved_type is MediaType.JPEG: + dimensions = _jpeg(head) + elif resolved_type is MediaType.GIF: + dimensions = _gif(head) + elif resolved_type is MediaType.WEBP: + dimensions = _webp(head) + elif resolved_type is MediaType.WEBM: + parsed = _webm(head) + if parsed: + dimensions = (parsed[0], parsed[1]) + duration = parsed[2] + elif resolved_type in _ISO_BMFF_TYPES: + dimensions = _iso_dimensions(head) + duration = _iso_duration(head) + if dimensions is None and len(data) > _HEAD_BYTES: + # QuickTime often writes `moov` at the end of the file + tail = data[-_HEAD_BYTES:] + dimensions = _iso_dimensions(tail) + duration = _iso_duration(tail) + + if dimensions is None: + raise _fail(name, f"unrecognised {resolved_type.value} header") + + width, height = dimensions + if width <= 0 or height <= 0: + raise _fail(name, f"header reported a {width}x{height} frame") + + return MediaInfo( + media_type=resolved_type, + width=width, + height=height, + duration_seconds=duration, + frames=frames_for(duration), + ) diff --git a/src/guard_client/py.typed b/src/guard_client/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/guard_client/reactions.py b/src/guard_client/reactions.py new file mode 100644 index 0000000..4c6f0a1 --- /dev/null +++ b/src/guard_client/reactions.py @@ -0,0 +1,337 @@ +""" +Low-level bindings for the `/api/v1/reactions/` endpoint. + +A reaction is feedback on one task result for one activity. It answers whether the +detection was right, and if not, what the expected result was. + +Two API rules shape this module: First, you may only react to your own activity. +Anything else returns a 404, which is indistinguishable from an unknown id. Second, only +one reaction is allowed per activity and task. A second attempt returns a 409 conflict. + +Reading or removing a reaction is an admin-only operation, so this resource is +create-only. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from .exceptions import GuardError +from .filters import IdLike, validate_length +from .models import ( + ActivityResultItem, + Reaction, + ResultSource, + activity_id_of, + ensure_bool, + result_items_of, +) +from .transport import AsyncTransport, SyncTransport + +__all__ = ["AsyncReactions", "Reactions"] + +#: The base URL path for the reactions endpoints. +_BASE = "/api/v1/reactions/" + +#: The server-side constraint on the maximum length of a reaction description. +DESCRIPTION_MAX_LENGTH = 255 + + +class _ReactionsBase: + """ + Query and payload construction with no network I/O. + + Everything that does not touch the network lives here. This ensures the synchronous + and asynchronous resources cannot drift in how they build or validate a request. + """ + + @staticmethod + def _create_payload( + *, + activity_id: IdLike, + task_id: IdLike, + is_positive: bool, + key_value: Optional[int], + description: Optional[str], + ) -> Dict[str, Any]: + """ + Build the JSON body for creating a reaction. + + The arguments mirror `Reactions.create`, but none are optional here. + + Args: + activity_id: The ID of the activity the result belongs to. + task_id: The ID of the task result being reacted to. + is_positive: Indicates if the detection was correct. + key_value: The expected result as an integer key. + description: A free text description. + + Returns: + The request body with every unset optional omitted. + + Raises: + GuardError: If `is_positive` is not a boolean, `key_value` is not an + integer, or `description` exceeds 255 characters. + """ + payload: Dict[str, Any] = { + "activity_id": str(activity_id), + "task_id": str(task_id), + "is_positive": ensure_bool(is_positive, field="is_positive"), + } + + if key_value is not None: + # bool subclasses int, so `isinstance(True, int)` is True. We check + # it out explicitly since True would silently become expected-result option + # 1 + if isinstance(key_value, bool) or not isinstance(key_value, int): + raise GuardError( + f"Invalid key_value={key_value!r}. Expected an integer key from " + f"the task's `reactions` map, e.g. " + f"task.reactions -> {{1: 'Real photo'}}" + ) + payload["key_value"] = key_value + + if description is not None: + clean = str(description).strip() + if clean: + payload["description"] = validate_length( + clean, field="description", max_len=DESCRIPTION_MAX_LENGTH + ) + + return payload + + @staticmethod + def _resolve_source(source: ResultSource, item: ActivityResultItem) -> IdLike: + """ + Get the activity id while checking the item really belongs to this result. + + Args: + source: A result from `analyze()` or `activities.get()`. + item: The result item being commented on. + + Returns: + The activity id behind the result. + + Raises: + GuardError: If the source came from the local engine, meaning no activity + exists on the server, or if the item belongs to a different activity. + """ + activity_id = activity_id_of(source) + if activity_id is None: + raise GuardError( + "This result came from the local engine, so no activity exists on the " + "server to react to. Reactions apply to cloud results only." + ) + + known = result_items_of(source) + if not any(existing.task_id == item.task_id for existing in known): + available = ", ".join(str(existing.task_id) for existing in known) or "none" + raise GuardError( + f"task_id {item.task_id} is not part of this activity's results " + f"(available: {available})." + ) + return activity_id + + +class Reactions(_ReactionsBase): + """ + Synchronous reaction endpoints. + + This class is accessed through the client rather than being constructed directly, + and it shares the client connection pool. + """ + + def __init__(self, transport: SyncTransport) -> None: + """ + Bind this resource to a transport. + + Args: + transport: The client transport whose connection pool is shared. + """ + self._transport = transport + + def create( + self, + *, + activity_id: IdLike, + task_id: IdLike, + is_positive: bool, + key_value: Optional[int] = None, + description: Optional[str] = None, + ) -> Reaction: + """ + Submit feedback on one task result. + + Args: + activity_id: The activity the result belongs to. It must be your own. + task_id: Which task result you are reacting to. + is_positive: `True` if the detection was correct. Accepts `True` or `False` + only. + key_value: The expected result as an integer key of the task `reactions` + map. See `Task.reactions` for the choices. + description: Free text up to 255 characters. Blank counts as unset. + + Returns: + The created `Reaction`. + + Raises: + GuardError: If a value is invalid. This is raised before any request is + sent. + GuardNotFoundError: If the activity is unknown or it is not yours. The API + does not distinguish between the two. + GuardConflictError: If you have already reacted to this activity and task. + + Examples: + ```python + client.reactions.create( + activity_id=result.activity_id, + task_id=item.task_id, + is_positive=False, + key_value=2, + description="This is a real photo of me", + ) + ``` + """ + payload = self._create_payload( + activity_id=activity_id, + task_id=task_id, + is_positive=is_positive, + key_value=key_value, + description=description, + ) + # not retried: a replay would trip the one-reaction-per-task guard and report a + # 409 for a reaction that actually succeeded + data = self._transport.request("POST", _BASE, json=payload) + return Reaction.model_validate(data) + + def create_for( + self, + source: ResultSource, + item: ActivityResultItem, + *, + is_positive: bool, + key_value: Optional[int] = None, + description: Optional[str] = None, + ) -> Reaction: + """ + React using the objects you already hold. + + Args: + source: Either a `DetectionResult` from `GuardClient.analyze` or an + `ActivityDetail` from `activities.get()`. + item: The `ActivityResultItem` being commented on. + is_positive: `True` if the detection was correct. + key_value: The expected result as an integer key of the task `reactions` + map. + description: Free text up to 255 characters. + + Returns: + The created `Reaction`. + + Raises: + GuardError: If the source is a local result or the item is not one of its + results. Both checks happen before any request is sent. + """ + activity_id = self._resolve_source(source, item) + return self.create( + activity_id=activity_id, + task_id=item.task_id, + is_positive=is_positive, + key_value=key_value, + description=description, + ) + + +class AsyncReactions(_ReactionsBase): + """ + Asynchronous reaction endpoints. + + This class mirrors `Reactions` method for method. See the synchronous methods for + full argument details. + """ + + def __init__(self, transport: AsyncTransport) -> None: + """ + Bind this resource to a transport. + + Args: + transport: The client transport whose connection pool is shared. + """ + self._transport = transport + + async def create( + self, + *, + activity_id: IdLike, + task_id: IdLike, + is_positive: bool, + key_value: Optional[int] = None, + description: Optional[str] = None, + ) -> Reaction: + """ + Submit feedback on one task result. + + Args: + activity_id: The activity the result belongs to. It must be your own. + task_id: Which task result you are reacting to. + is_positive: `True` if the detection was correct. Accepts `True` or `False` + only. + key_value: The expected result as an integer key of the task `reactions` + map. + description: Free text up to 255 characters. Blank counts as unset. + + Returns: + The created `Reaction`. Review `Reactions.create` for more details. + + Raises: + GuardError: If a value is invalid. This is raised before any request. + GuardNotFoundError: If the activity is unknown or not yours. + GuardConflictError: If you already reacted to this activity and task. + """ + payload = self._create_payload( + activity_id=activity_id, + task_id=task_id, + is_positive=is_positive, + key_value=key_value, + description=description, + ) + # not retried: see the note on Reactions.create + data = await self._transport.request("POST", _BASE, json=payload) + return Reaction.model_validate(data) + + async def create_for( + self, + source: ResultSource, + item: ActivityResultItem, + *, + is_positive: bool, + key_value: Optional[int] = None, + description: Optional[str] = None, + ) -> Reaction: + """ + React using the objects you already hold. + + Args: + source: Either a `DetectionResult` from `GuardClient.analyze` or an + `ActivityDetail` from `activities.get()`. + item: The `ActivityResultItem` being commented on. + is_positive: `True` if the detection was correct. + key_value: The expected result as an integer key of the task `reactions` + map. + description: Free text up to 255 characters. + + Returns: + The created `Reaction`. Review `Reactions.create_for` for more details. + + Raises: + GuardError: If the source is a local result or the item is not one of its + results. + """ + activity_id = self._resolve_source(source, item) + return await self.create( + activity_id=activity_id, + task_id=item.task_id, + is_positive=is_positive, + key_value=key_value, + description=description, + ) diff --git a/src/guard_client/runners.py b/src/guard_client/runners.py new file mode 100644 index 0000000..aab561f --- /dev/null +++ b/src/guard_client/runners.py @@ -0,0 +1,551 @@ +""" +Low-level bindings for the `/api/v1/runners/` endpoints. + +A runner is a dedicated compute instance serving one predictor for an organization. +Listing them supplies the `dedicated_runner_ids` accepted by `Spaces.create`. + +Note: + These routes currently authenticate a *user* rather than a service account. A + service-account key will get a 404 here. The API is expected to widen this scope, + and nothing in the client will need to change when it does. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Union + +from .exceptions import GuardError +from .filters import ( + MAX_LIMIT, + IdLike, + add_ids, + add_sort, + add_statuses, + id_list, + validate_pagination, +) +from .models import ( + FILTERABLE_RUNNER_STATUSES, + Runner, + RunnerOrder, + RunnerPage, + RunnerStatus, + SortOrder, + ensure_bool, +) +from .transport import AsyncTransport, SyncTransport + +__all__ = ["AsyncRunners", "Runners"] + +#: The base URL path for runner endpoints. +_BASE = "/api/v1/runners/" + +#: A type alias for runner statuses. +StatusLike = Union[RunnerStatus, str] + + +class _RunnersBase: + """ + Query and payload construction with no network I/O. + + Everything that does not touch the network lives here. This ensures the synchronous + and asynchronous resources cannot drift in how they build or validate a request. + """ + + def __init__(self, default_organization_id: Optional[IdLike] = None) -> None: + """ + Remember the organization to fall back on. + + Args: + default_organization_id: Used when a call omits it. Runners are always + organization-scoped, so this is required one way or another. + """ + self._default_organization_id = default_organization_id + + def _resolve_organization_id(self, organization_id: Optional[IdLike]) -> str: + """ + Pick the organization for this call. + + Args: + organization_id: The per-call value, or `None` to use the client default. + + Returns: + The organization id as a string. + + Raises: + GuardError: If neither source supplied an ID. Unlike other list endpoints, + the API requires this, so there is no unfiltered fallback. + """ + effective = ( + organization_id + if organization_id is not None + else self._default_organization_id + ) + if effective is None: + raise GuardError( + "organization_id is required for runners. Pass it to this call, set it " + "on the client with GuardClient(organization_id=...), or put " + "GUARD_ORGANIZATION_ID in your environment or .env file." + ) + return str(effective) + + def _list_params( + self, + *, + organization_id: Optional[IdLike], + predictor_id: Optional[IdLike], + statuses: Optional[Sequence[StatusLike]], + sort_by: Optional[Union[RunnerOrder, str]], + sort_order: Optional[Union[SortOrder, str]], + skip: int, + limit: int, + ) -> Dict[str, Any]: + """ + Build the query parameters for a list request. + + Validating here means a bad filter never reaches the network. The arguments + mirror `list`, but none are optional here. + + Args: + organization_id: Only runners belonging to this organization. + predictor_id: Only runners serving this predictor. + statuses: Keep only runners with these statuses. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + skip: Offset of the results. Must be 0 or greater. + limit: Page size, 1-100. + + Returns: + The query dict with every unset filter omitted. + + Raises: + GuardError: If a filter value is invalid. + """ + validate_pagination(skip, limit) + + params: Dict[str, Any] = { + "skip": skip, + "limit": limit, + "organization_id": self._resolve_organization_id(organization_id), + } + add_ids(params, predictor_id=predictor_id) + add_statuses(params, statuses, RunnerStatus, allowed=FILTERABLE_RUNNER_STATUSES) + add_sort(params, sort_by, sort_order, RunnerOrder) + return params + + def _create_payload( + self, + *, + predictor_id: IdLike, + organization_id: Optional[IdLike], + is_default: bool, + dedicated_space_ids: Optional[Sequence[IdLike]], + ) -> Dict[str, Any]: + """ + Build the JSON body for creating a runner. + + The arguments mirror `Runners.create`, but none are optional here. + + Args: + predictor_id: The ID of the predictor this runner serves. + organization_id: The ID of the organization to own the runner. + is_default: Whether spaces get this as their default runner. + dedicated_space_ids: Sequence of space IDs to restrict the runner to. + + Returns: + The request body. Unlike spaces, `is_default` is always sent since the API + accepts it here. + + Raises: + GuardError: If no organization id is available or `is_default` is not a + boolean. + """ + payload: Dict[str, Any] = { + "predictor_id": str(predictor_id), + "organization_id": self._resolve_organization_id(organization_id), + # unlike spaces, the API does accept is_default at creation here + "is_default": ensure_bool(is_default, field="is_default"), + } + space_ids = id_list(dedicated_space_ids, field="dedicated_space_ids") + if space_ids is not None: + payload["dedicated_space_ids"] = space_ids + return payload + + +class Runners(_RunnersBase): + """ + Synchronous runner endpoints. + + This class is accessed through the client rather than being constructed directly, + and it shares the client connection pool. + """ + + def __init__( + self, + transport: SyncTransport, + *, + default_organization_id: Optional[IdLike] = None, + ) -> None: + """ + Bind this resource to a transport with an optional default id. + + Args: + transport: The client transport whose connection pool is shared. + default_organization_id: Used when a call omits it. The id can be set once + on the client instead of on every call. + """ + super().__init__(default_organization_id) + self._transport = transport + + def list( + self, + *, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[RunnerOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> RunnerPage: + """ + List an organization's runners. + + Args: + organization_id: Required by the API. Falls back to the client default. + predictor_id: Only runners serving this predictor. + statuses: Keep only runners with these statuses. `"terminated"` is not + filterable. Review `FILTERABLE_RUNNER_STATUSES` for details. + sort_by: `"name"` or `"created_at"`. Server default: `"created_at"`. + sort_order: `"asc"` or `"desc"`. Server default: `"asc"`. + skip: Offset. 0 or greater. + limit: Page size, 1-100. + + Returns: + A `RunnerPage`. You can iterate it like a list or read `.count`. + + Raises: + GuardError: If no organization id is available or a filter value is invalid. + GuardNotFoundError: If you are not a member of the organization. + """ + params = self._list_params( + organization_id=organization_id, + predictor_id=predictor_id, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = self._transport.request("GET", _BASE, params=params) + return RunnerPage.model_validate(data) + + def iter_all( + self, + *, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[RunnerOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> Iterator[Runner]: + """ + Yield every matching runner by fetching pages as needed. + + Args: + organization_id: Only runners belonging to this organization. + predictor_id: Only runners serving this predictor. + statuses: Keep only runners with these statuses. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. + + Yields: + Each matching runner with the oldest page first. + + Note: + Pages are fetched lazily. Breaking out of the loop early stops the requests + rather than paying for the whole set. + """ + skip = 0 + while True: + page = self.list( + organization_id=organization_id, + predictor_id=predictor_id, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + yield from page.data + + skip += len(page.data) + if not page.data or len(page.data) < page_size or skip >= page.count: + return + + def get(self, runner_id: IdLike) -> Runner: + """ + Read one runner. + + Args: + runner_id: The ID of the runner to fetch. + + Returns: + The requested `Runner`. + + Raises: + GuardNotFoundError: If the runner is unknown or you are not a + member of its organization. The API does not distinguish the two. + """ + data = self._transport.request("GET", f"{_BASE}{runner_id}") + return Runner.model_validate(data) + + def create( + self, + *, + predictor_id: IdLike, + organization_id: Optional[IdLike] = None, + is_default: bool = False, + dedicated_space_ids: Optional[Sequence[IdLike]] = None, + ) -> Runner: + """ + Create a runner and start its deployment. + + This requires ownership of the organization, not merely membership. + + Args: + predictor_id: The predictor this runner serves. Must be enabled in the + organization's active plan. + organization_id: The organization to own the runner. Falls back to the + client default. + is_default: Whether spaces get this as their default runner. + dedicated_space_ids: Restrict the runner to these spaces. They must belong + to the same organization. Duplicates are dropped and order is preserved. + + Returns: + The created `Runner` with an initial status of `pending`. + + Raises: + GuardError: If a value is invalid. Raised before any request is sent. + GuardPaymentRequiredError: If there is no active subscription or the runner + limit is reached. + GuardConflictError: If a runner with this name already exists here. + GuardNotFoundError: If the organization, predictor, or space is unknown, or + if you do not own the organization. + + Examples: + ```python + runner = client.runners.create(predictor_id=P) + print(runner.status) # + ``` + """ + payload = self._create_payload( + predictor_id=predictor_id, + organization_id=organization_id, + is_default=is_default, + dedicated_space_ids=dedicated_space_ids, + ) + data = self._transport.request("POST", _BASE, json=payload) + return Runner.model_validate(data) + + def delete(self, runner_id: IdLike) -> None: + """ + Delete a runner. + + The API drains the runner, tears down its deployment, and then removes the + record. This is not reversible. + + Args: + runner_id: The ID of the runner to delete. + + Raises: + GuardNotFoundError: If the runner is unknown or you do not own its + organization. + GuardServerError: If draining or teardown fails. The runner is left with a + `failed` status. + """ + # never retried: a replay after a dropped connection would report a confusing + # 404 for a delete that actually succeeded, or re-trigger a live teardown + self._transport.request("DELETE", f"{_BASE}{runner_id}", retry=False) + + +class AsyncRunners(_RunnersBase): + """ + Asynchronous runner endpoints. + + This class mirrors `Runners` method for method. See the synchronous methods for + full argument details. + """ + + def __init__( + self, + transport: AsyncTransport, + *, + default_organization_id: Optional[IdLike] = None, + ) -> None: + """ + Bind this resource to a transport with an optional default id. + + Args: + transport: The client transport whose connection pool is shared. + default_organization_id: Used when a call omits it. The id can be set once + on the client instead of on every call. + """ + super().__init__(default_organization_id) + self._transport = transport + + async def list( + self, + *, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[RunnerOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> RunnerPage: + """ + List the runners matching the given filters. + + Args: + organization_id: Only runners belonging to this organization. + predictor_id: Only runners serving this predictor. + statuses: Keep only runners with these statuses. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + skip: Offset. 0 or greater. + limit: Page size, 1-100. + + Returns: + A `RunnerPage`. Review `Runners.list` for full filter details. + """ + params = self._list_params( + organization_id=organization_id, + predictor_id=predictor_id, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = await self._transport.request("GET", _BASE, params=params) + return RunnerPage.model_validate(data) + + async def iter_all( + self, + *, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[RunnerOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> AsyncIterator[Runner]: + """ + Yield every matching runner by fetching pages as needed. + + Args: + organization_id: Only runners belonging to this organization. + predictor_id: Only runners serving this predictor. + statuses: Keep only runners with these statuses. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. + + Yields: + Each matching runner. Review `Runners.iter_all` for more context. + """ + skip = 0 + while True: + page = await self.list( + organization_id=organization_id, + predictor_id=predictor_id, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + for runner in page.data: + yield runner + + skip += len(page.data) + if not page.data or len(page.data) < page_size or skip >= page.count: + return + + async def get(self, runner_id: IdLike) -> Runner: + """ + Read one runner. + + Args: + runner_id: The ID of the runner to fetch. + + Returns: + The requested `Runner`. + + Raises: + GuardNotFoundError: If the runner is unknown or you are not a member of its + organization. + """ + data = await self._transport.request("GET", f"{_BASE}{runner_id}") + return Runner.model_validate(data) + + async def create( + self, + *, + predictor_id: IdLike, + organization_id: Optional[IdLike] = None, + is_default: bool = False, + dedicated_space_ids: Optional[Sequence[IdLike]] = None, + ) -> Runner: + """ + Create a runner and start its deployment. + + Args: + predictor_id: The predictor this runner serves. Must be enabled in the + organization's active plan. + organization_id: The organization to own the runner. Falls back to the + client default. + is_default: Whether spaces get this as their default runner. + dedicated_space_ids: Sequence of space IDs to restrict the runner to. + + Returns: + The created `Runner` with an initial status of `pending`. + + Raises: + GuardError: If a value is invalid. Raised before any request is sent. + GuardPaymentRequiredError: If there is no active subscription or the runner + limit is reached. + GuardConflictError: If a runner with this name already exists here. + GuardNotFoundError: If the organization, predictor, or space is unknown. + """ + payload = self._create_payload( + predictor_id=predictor_id, + organization_id=organization_id, + is_default=is_default, + dedicated_space_ids=dedicated_space_ids, + ) + data = await self._transport.request("POST", _BASE, json=payload) + return Runner.model_validate(data) + + async def delete(self, runner_id: IdLike) -> None: + """ + Delete a runner. This is not reversible. + + Args: + runner_id: The ID of the runner to delete. + + Raises: + GuardNotFoundError: If the runner is unknown or you do not own its + organization. + GuardServerError: If draining or teardown fails. + + Warning: + This tears down the runner's deployment. Review `Runners.delete` for more + details. + """ + # never retried: see the note on Runners.delete + await self._transport.request("DELETE", f"{_BASE}{runner_id}", retry=False) diff --git a/src/guard_client/shares.py b/src/guard_client/shares.py new file mode 100644 index 0000000..3dcdf0f --- /dev/null +++ b/src/guard_client/shares.py @@ -0,0 +1,555 @@ +""" +Low-level bindings for the `/api/v1/activities/shares` endpoints. + +A share is a public link to one task result for an activity. Three API facts shape this +module: First, the activity must have finished processing. The task must appear in its +results, or the API answers 400 `Invalid task`. Second, only one share is allowed per +activity, and there is no delete endpoint. A link cannot be revoked; it can only be left +to expire. Third, authentication requires a full identity. As everywhere in this client, +an API key is required. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Union + +from .exceptions import GuardError +from .filters import ( + MAX_LIMIT, + IdLike, + add_ids, + add_sort, + add_statuses, + validate_pagination, +) +from .models import ( + ActivityResultItem, + ResultSource, + Share, + ShareOrder, + SharePage, + ShareStatus, + SortOrder, + activity_id_of, + result_items_of, +) +from .transport import AsyncTransport, SyncTransport + +__all__ = ["AsyncShares", "Shares"] + +#: The base URL path for the shares endpoints. +_BASE = "/api/v1/activities/shares/" + +#: Server-side minimum bound on `expires_in` (days). +MIN_EXPIRES_IN = 1 + +#: Server-side maximum bound on `expires_in` (days). +MAX_EXPIRES_IN = 7 + +#: A type alias for share statuses. +StatusLike = Union[ShareStatus, str] + + +class _SharesBase: + """ + Query and payload construction with no network I/O. + + Everything that does not touch the network lives here. This ensures the synchronous + and asynchronous resources cannot drift in how they build or validate a request. + """ + + @staticmethod + def _list_params( + *, + user_id: Optional[IdLike], + organization_id: Optional[IdLike], + statuses: Optional[Sequence[StatusLike]], + sort_by: Optional[Union[ShareOrder, str]], + sort_order: Optional[Union[SortOrder, str]], + skip: int, + limit: int, + ) -> Dict[str, Any]: + """ + Build the query parameters for a list request. + + Validating here means a bad filter never reaches the network. The arguments + mirror `list`, but none are optional here. + + Args: + user_id: Only shares belonging to this user. + organization_id: Only shares belonging to this organization. + statuses: Keep only shares with these statuses. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + skip: Offset of the results. Must be 0 or greater. + limit: Page size, 1-100. + + Returns: + The query dict with every unset filter omitted. + + Raises: + GuardError: If a filter value is invalid. + """ + validate_pagination(skip, limit) + + params: Dict[str, Any] = {"skip": skip, "limit": limit} + # like /activities/ and unlike /spaces/, this route applies both owner filters + # independently rather than rejecting the combination + add_ids(params, user_id=user_id, organization_id=organization_id) + add_statuses(params, statuses, ShareStatus) + add_sort(params, sort_by, sort_order, ShareOrder) + return params + + @staticmethod + def _create_payload( + *, + activity_id: IdLike, + task_id: IdLike, + expires_in: Optional[int], + ) -> Dict[str, Any]: + """ + Build the JSON body for creating a share. + + The arguments mirror `Shares.create`, but none are optional here. + + Args: + activity_id: The ID of the activity to share. + task_id: The ID of the task result to share. + expires_in: Lifetime in days, 1-7. + + Returns: + The request body. It omits `expires_in` when unset so the server default of + seven days applies. + + Raises: + GuardError: If `expires_in` is not an integer or falls outside 1-7. + """ + payload: Dict[str, Any] = { + "activity_id": str(activity_id), + "task_id": str(task_id), + } + if expires_in is not None: + # bool subclasses int, so True would otherwise pass as 1 day + if isinstance(expires_in, bool) or not isinstance(expires_in, int): + raise GuardError( + f"Invalid expires_in={expires_in!r}. Expected an integer number " + f"of days" + ) + if not MIN_EXPIRES_IN <= expires_in <= MAX_EXPIRES_IN: + raise GuardError( + f"Invalid expires_in={expires_in}. Expected between " + f"{MIN_EXPIRES_IN} and {MAX_EXPIRES_IN} days" + ) + payload["expires_in"] = expires_in + return payload + + @staticmethod + def _resolve_source(source: ResultSource, item: ActivityResultItem) -> IdLike: + """ + Get the activity id while checking the item really belongs to this result. + + The membership check is the local stand-in for the API 400 `Invalid task` error, + which it raises when the task is absent from the activity results. + + Args: + source: A result from `analyze()` or `activities.get()`. + item: The result item being shared. + + Returns: + The activity id behind the result. + + Raises: + GuardError: If the source came from the local engine, meaning no activity + exists on the server, or if the item belongs to a different activity. + """ + activity_id = activity_id_of(source) + if activity_id is None: + raise GuardError( + "This result came from the local engine, so no activity exists on the " + "server to share. Shares apply to cloud results only." + ) + + known = result_items_of(source) + if not any(existing.task_id == item.task_id for existing in known): + available = ", ".join(str(existing.task_id) for existing in known) or "none" + raise GuardError( + f"task_id {item.task_id} is not part of this activity's results " + f"(available: {available}). Only a completed activity can be shared." + ) + return activity_id + + +class Shares(_SharesBase): + """ + Synchronous activity-share endpoints. + + This class is accessed through the client rather than being constructed directly, + and it shares the client connection pool. + """ + + def __init__(self, transport: SyncTransport) -> None: + """ + Bind this resource to a transport. + + Args: + transport: The client transport whose connection pool is shared. + """ + self._transport = transport + + def list( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[ShareOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> SharePage: + """ + List the activity shares visible to you. + + Args: + user_id: Only your own ID is accepted. Anything else returns a 404. A + service account passing this at all gets a 403. + organization_id: Requires editor or owner membership. + statuses: Valid options include `"active"` and/or `"expired"`. + sort_by: Valid options include `"created_at"`. Server default: + `"created_at"`. + sort_order: `"asc"` or `"desc"`. Server default for shares: `"desc"`. + skip: Offset. 0 or greater. + limit: Page size, 1-100. + + Returns: + A `SharePage`. You can iterate it like a list or read `.count`. + + Note: + There is no `activity_id` filter. Shares cannot be looked up by the activity + they belong to. + """ + params = self._list_params( + user_id=user_id, + organization_id=organization_id, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = self._transport.request("GET", _BASE, params=params) + return SharePage.model_validate(data) + + def iter_all( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[ShareOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> Iterator[Share]: + """ + Yield every matching share by fetching pages as needed. + + Args: + user_id: Only shares available to this user. + organization_id: Only shares available to this organization. + statuses: Valid options include `"active"` and/or `"expired"`. + sort_by: Valid options include `"created_at"`. + sort_order: `"asc"` or `"desc"`. + page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. + + Yields: + Each matching share with the oldest page first. + + Note: + Pages are fetched lazily. Breaking out of the loop early stops the requests + rather than paying for the whole set. + """ + skip = 0 + while True: + page = self.list( + user_id=user_id, + organization_id=organization_id, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + yield from page.data + + skip += len(page.data) + if not page.data or len(page.data) < page_size or skip >= page.count: + return + + def get(self, share_id: IdLike) -> Share: + """ + Read one share. + + Args: + share_id: The ID of the share to fetch. + + Returns: + The requested `Share`. + + Raises: + GuardNotFoundError: If the share is unknown, expired beyond visibility, or + not yours. + """ + data = self._transport.request("GET", f"{_BASE}{share_id}") + return Share.model_validate(data) + + def create( + self, + *, + activity_id: IdLike, + task_id: IdLike, + expires_in: Optional[int] = None, + ) -> Share: + """ + Create a public link to one task result. + + Args: + activity_id: The activity to share. Must be your own and completed. + task_id: Which task result the link shows. It must appear in the activity + results or the API answers 400. + expires_in: Lifetime in days, 1-7. Omitting it means the server + default of 7 applies. + + Returns: + The created `Share` carrying `share_url`. + + Raises: + GuardError: If `expires_in` is out of range. Raised before any request. + GuardNotFoundError: If the activity is unknown, not yours, or its media is + gone. + GuardConflictError: If this activity has already been shared. + GuardAPIError: 400 error if the task is not in the activity results, + or if the task has no associated media. + + Examples: + ```python + share = client.shares.create(activity_id=A, task_id=T, expires_in=1) + print(share.share_url) + ``` + + Note: + There is no way to revoke a share. It lives until it expires. + """ + payload = self._create_payload( + activity_id=activity_id, task_id=task_id, expires_in=expires_in + ) + # not retried: a replay would trip the one-share-per-activity guard and report a + # 409 for a share that actually succeeded + data = self._transport.request("POST", _BASE, json=payload) + return Share.model_validate(data) + + def create_for( + self, + source: ResultSource, + item: ActivityResultItem, + *, + expires_in: Optional[int] = None, + ) -> Share: + """ + Share using the objects you already hold. + + Args: + source: Either a `DetectionResult` from `GuardClient.analyze` or an + `ActivityDetail` from `activities.get()`. + item: The `ActivityResultItem` being shared. + expires_in: Lifetime in days, 1-7. Omitting it means the server default of 7 + applies. + + Returns: + The created `Share` carrying `share_url`. + + Raises: + GuardError: If the source is a local result or the item is not one of its + results. Both checks happen before any request is sent. + """ + activity_id = self._resolve_source(source, item) + return self.create( + activity_id=activity_id, task_id=item.task_id, expires_in=expires_in + ) + + +class AsyncShares(_SharesBase): + """ + Asynchronous activity-share endpoints. + + This class mirrors `Shares` method for method. See the synchronous methods for full + argument details. + """ + + def __init__(self, transport: AsyncTransport) -> None: + """ + Bind this resource to a transport. + + Args: + transport: The client transport whose connection pool is shared. + """ + self._transport = transport + + async def list( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[ShareOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> SharePage: + """ + List the shares matching the given filters. + + Args: + user_id: Only your own ID is accepted. Anything else returns a 404. A + service account passing this at all gets a 403. + organization_id: Requires editor or owner membership. + statuses: Valid options include `"active"` and/or `"expired"`. + sort_by: Valid options include `"created_at"`. + sort_order: `"asc"` or `"desc"`. + skip: Offset. 0 or greater. + limit: Page size, 1-100. + + Returns: + A `SharePage`. Review `Shares.list` for full filter details. + """ + params = self._list_params( + user_id=user_id, + organization_id=organization_id, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = await self._transport.request("GET", _BASE, params=params) + return SharePage.model_validate(data) + + async def iter_all( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[ShareOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> AsyncIterator[Share]: + """ + Yield every matching share by fetching pages as needed. + + Args: + user_id: Only shares available to this user. + organization_id: Only shares available to this organization. + statuses: Valid options include `"active"` and/or `"expired"`. + sort_by: Valid options include `"created_at"`. + sort_order: `"asc"` or `"desc"`. + page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. + + Yields: + Each matching share. Review `Shares.iter_all` for more context. + """ + skip = 0 + while True: + page = await self.list( + user_id=user_id, + organization_id=organization_id, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + for share in page.data: + yield share + + skip += len(page.data) + if not page.data or len(page.data) < page_size or skip >= page.count: + return + + async def get(self, share_id: IdLike) -> Share: + """ + Read one share. + + Args: + share_id: The ID of the share to fetch. + + Returns: + The requested `Share`. + + Raises: + GuardNotFoundError: If the share is unknown or not yours. + """ + data = await self._transport.request("GET", f"{_BASE}{share_id}") + return Share.model_validate(data) + + async def create( + self, + *, + activity_id: IdLike, + task_id: IdLike, + expires_in: Optional[int] = None, + ) -> Share: + """ + Create a public link to one task result. + + Args: + activity_id: The activity to share. Must be your own and completed. + task_id: Which task result the link shows. It must appear in the activity + results or the API answers 400. + expires_in: Lifetime in days, 1-7. Omitting it means the server default of + 7 applies. + + Returns: + The created `Share` carrying `share_url`. Review `Shares.create` for more + details. + + Raises: + GuardError: If `expires_in` is out of range. + GuardConflictError: If this activity has already been shared. + GuardNotFoundError: If the activity is unknown, not yours, or its media is + gone. + """ + payload = self._create_payload( + activity_id=activity_id, task_id=task_id, expires_in=expires_in + ) + # not retried: see the note on Shares.create + data = await self._transport.request("POST", _BASE, json=payload) + return Share.model_validate(data) + + async def create_for( + self, + source: ResultSource, + item: ActivityResultItem, + *, + expires_in: Optional[int] = None, + ) -> Share: + """ + Share using the objects you already hold. + + Args: + source: Either a `DetectionResult` from `GuardClient.analyze` or an + `ActivityDetail` from `activities.get()`. + item: The `ActivityResultItem` being shared. + expires_in: Lifetime in days, 1-7. Omitting it means the server default of 7 + applies. + + Returns: + The created `Share`. Review `Shares.create_for` for more details. + + Raises: + GuardError: If the source is a local result or the item is not one of its + results. Both checks happen before any request is sent. + """ + activity_id = self._resolve_source(source, item) + return await self.create( + activity_id=activity_id, task_id=item.task_id, expires_in=expires_in + ) diff --git a/src/guard_client/spaces.py b/src/guard_client/spaces.py new file mode 100644 index 0000000..e50d8b1 --- /dev/null +++ b/src/guard_client/spaces.py @@ -0,0 +1,639 @@ +""" +Low-level bindings for the `/api/v1/spaces/` endpoints. + +Spaces are the containers activities are created in. Listing them is how a caller +discovers the `space_id` that `GuardClient.analyze` needs. + +`Spaces` and `AsyncSpaces` mirror each other. All query construction lives on +`_SpacesBase` so the two classes cannot drift. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Union + +from .exceptions import GuardError +from .filters import ( + MAX_LIMIT, + IdLike, + add_bool, + add_ids, + add_sort, + add_statuses, + id_list, + reject_conflicting_owners, + require_exactly_one_owner, + validate_length, + validate_pagination, +) +from .models import ( + SortOrder, + Space, + SpaceDetail, + SpaceOrder, + SpacePage, + SpaceStatus, + ensure_bool, +) +from .transport import AsyncTransport, SyncTransport + +__all__ = ["AsyncSpaces", "Spaces"] + +#: The base URL path for the spaces endpoints. +_BASE = "/api/v1/spaces/" + +#: Minimum length for a space name. +NAME_MIN_LENGTH = 3 + +#: Maximum length for a space name. +NAME_MAX_LENGTH = 50 + +#: Maximum length for a space description. +DESCRIPTION_MAX_LENGTH = 2000 + +#: A type alias for space statuses. +StatusLike = Union[SpaceStatus, str] + + +class _SpacesBase: + """ + Query and payload construction with no network I/O. + + Everything that does not touch the network lives here. This ensures the synchronous + and asynchronous resources cannot drift in how they build or validate a request. + """ + + def __init__(self, default_organization_id: Optional[IdLike] = None) -> None: + """ + Remember the organization to fall back on. + + Args: + default_organization_id: This owns a created space when neither `user_id` + nor `organization_id` is given. An explicit `user_id` still overrides + this. + """ + self._default_organization_id = default_organization_id + + @staticmethod + def _list_params( + *, + user_id: Optional[IdLike], + organization_id: Optional[IdLike], + predictor_id: Optional[IdLike], + is_public: Optional[bool], + is_default: Optional[bool], + statuses: Optional[Sequence[StatusLike]], + sort_by: Optional[Union[SpaceOrder, str]], + sort_order: Optional[Union[SortOrder, str]], + skip: int, + limit: int, + ) -> Dict[str, Any]: + """ + Build the query parameters for a list request. + + Validating here means a bad filter never reaches the network. The arguments + mirror the `list` method, but none are optional here. + + Args: + user_id: Only spaces owned by this user. + organization_id: Only spaces owned by this organization. + predictor_id: Only spaces using this predictor. + is_public: Filter by public visibility. + is_default: Filter to default spaces. + statuses: Keep only these statuses. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + skip: Offset of the results. Must be 0 or greater. + limit: Page size, 1-100. + + Returns: + The query dict with every unset filter omitted. + + Raises: + GuardError: If a filter value is invalid. + """ + # validate before building so a bad filter never reaches the network + reject_conflicting_owners(user_id, organization_id) + validate_pagination(skip, limit) + + params: Dict[str, Any] = {"skip": skip, "limit": limit} + add_ids( + params, + user_id=user_id, + organization_id=organization_id, + predictor_id=predictor_id, + ) + add_bool(params, "is_public", is_public) + add_bool(params, "is_default", is_default) + add_statuses(params, statuses, SpaceStatus) + add_sort(params, sort_by, sort_order, SpaceOrder) + return params + + def _create_payload( + self, + *, + name: str, + predictor_id: IdLike, + description: Optional[str], + is_public: bool, + user_id: Optional[IdLike], + organization_id: Optional[IdLike], + enabled_task_ids: Optional[Sequence[IdLike]], + dedicated_runner_ids: Optional[Sequence[IdLike]], + ) -> Dict[str, Any]: + """ + Build the JSON body for creating a space. + + Validating here means an invalid space never reaches the network. The arguments + mirror `Spaces.create`, but none are optional here. + + Args: + name: The display name of the space. + predictor_id: The model powering the space. + description: A longer description of the space. + is_public: Whether everyone can see the space. + user_id: The owning user. Mutually exclusive with `organization_id`. + organization_id: The owning organization. + enabled_task_ids: The tasks to enable in the space. + dedicated_runner_ids: Allowed for organization spaces only. + + Returns: + The request body. It omits every unset optional field so the server applies + its own defaults. + + Raises: + GuardError: If a value is out of range, the owner rule was broken, or + runners were given for a user space. + """ + # fall back to the client's organization only when no owner was named at all, + # so an explicit user_id still produces a personal space + if user_id is None and organization_id is None: + organization_id = self._default_organization_id + require_exactly_one_owner(user_id, organization_id) + + # strip before measuring: the server strips too, so " ab " is two characters + # to it, and validating the raw string would let a too-short name through + clean_name = validate_length( + str(name).strip(), + field="name", + min_len=NAME_MIN_LENGTH, + max_len=NAME_MAX_LENGTH, + ) + + payload: Dict[str, Any] = { + "name": clean_name, + "predictor_id": str(predictor_id), + "is_public": ensure_bool(is_public, field="is_public"), + } + + if description is not None: + clean_description = str(description).strip() + if clean_description: + payload["description"] = validate_length( + clean_description, + field="description", + max_len=DESCRIPTION_MAX_LENGTH, + ) + + if user_id is not None: + payload["user_id"] = str(user_id) + else: + payload["organization_id"] = str(organization_id) + + task_ids = id_list(enabled_task_ids, field="enabled_task_ids") + if task_ids is not None: + payload["enabled_task_ids"] = task_ids + + runner_ids = id_list(dedicated_runner_ids, field="dedicated_runner_ids") + if runner_ids: + if user_id is not None: + raise GuardError( + "dedicated_runner_ids is only available for organization spaces. " + "Pass organization_id instead of user_id, or drop the runners." + ) + payload["dedicated_runner_ids"] = runner_ids + + return payload + + +class Spaces(_SpacesBase): + """ + Synchronous space endpoints. + + This class is accessed through the client rather than being constructed directly, + and it shares the client connection pool. + """ + + def __init__( + self, + transport: SyncTransport, + *, + default_organization_id: Optional[IdLike] = None, + ) -> None: + """ + Bind this resource to a transport with an optional default id. + + Args: + transport: The client transport whose connection pool is shared. + default_organization_id: Used when a call omits it. The id can be set once + on the client instead of on every call. + """ + super().__init__(default_organization_id) + self._transport = transport + + def create( + self, + *, + name: str, + predictor_id: IdLike, + description: Optional[str] = None, + is_public: bool = False, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + enabled_task_ids: Optional[Sequence[IdLike]] = None, + dedicated_runner_ids: Optional[Sequence[IdLike]] = None, + ) -> Space: + """ + Create a space. + + A space belongs to exactly one owner. You must pass either `user_id` or + `organization_id`, but never both and never neither. + + Args: + name: Must be between 3 and 50 characters after surrounding whitespace is + stripped. + predictor_id: The model powering the space from `Predictors.list`. It must + be enabled in your active plan. + description: Up to 2000 characters. Blank is treated as unset. + is_public: Whether everyone can see the space. Accepts `True` or `False` + only. A public space cannot be created inside a private organization. + user_id: The owning user. Mutually exclusive with `organization_id`. + organization_id: The owning organization. + enabled_task_ids: Tasks to enable from `Tasks.list`. Duplicates are dropped + and the order is preserved. + dedicated_runner_ids: Allowed for organization spaces only. Rejected for + user spaces. + + Returns: + The created `Space`. + + Raises: + GuardError: If a value is invalid or the owner rule was broken. Raised + before any request is sent. + GuardConflictError: If a space with this name already exists in this + context. + GuardPaymentRequiredError: If there is no active subscription or the space + limit is reached. + GuardAuthError: If the predictor is not enabled in your active plan. + GuardNotFoundError: If a task, runner, user, or organization id is unknown. + + Examples: + ```python + predictor = client.predictors.list()[0] + tasks = client.tasks.list(predictor_id=predictor.id) + space = client.spaces.create( + name="My Space", + predictor_id=predictor.id, + organization_id=ORG_ID, + enabled_task_ids=[t.id for t in tasks], + ) + ``` + + Note: + `is_default` is deliberately absent. The API rejects it for both user and + organization spaces, so it can never be set at creation time. + """ + payload = self._create_payload( + name=name, + predictor_id=predictor_id, + description=description, + is_public=is_public, + user_id=user_id, + organization_id=organization_id, + enabled_task_ids=enabled_task_ids, + dedicated_runner_ids=dedicated_runner_ids, + ) + # never retried: creating a space is not idempotent, so replaying a timed-out + # request risks a second space or a spurious 409 + data = self._transport.request("POST", _BASE, json=payload) + return Space.model_validate(data) + + def list( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + is_public: Optional[bool] = None, + is_default: Optional[bool] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[SpaceOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> SpacePage: + """ + List the spaces available to this API key. + + Enum filters accept either the member or its string value. + `sort_by=SpaceOrder.NAME` and `sort_by="name"` are equivalent and are validated + locally against the real enums. Boolean filters take only `True` or `False`. + + Args: + user_id: Only spaces owned by this user. Mutually exclusive with + `organization_id`. + organization_id: Only spaces owned by this organization. + predictor_id: Only spaces using this predictor. + is_public: Filter by public visibility. Accepts `True` or `False`. + is_default: Filter to default spaces. Accepts `True` or `False`. + statuses: Keep only these statuses. Publicly only `"active"` exists. + sort_by: Valid options include `"name"` or `"created_at"`. Server default: + `"created_at"`. + sort_order: `"asc"` or `"desc"`. Server default for spaces: `"asc"`. + skip: Offset. 0 or greater. + limit: Page size, 1-100. + + Returns: + A `SpacePage`. You can iterate it like a list or read `.count` for the total + number matching the filter across all pages. + + Raises: + GuardError: If a filter value is invalid or both owner filters were given. + """ + params = self._list_params( + user_id=user_id, + organization_id=organization_id, + predictor_id=predictor_id, + is_public=is_public, + is_default=is_default, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = self._transport.request("GET", _BASE, params=params) + return SpacePage.model_validate(data) + + def get(self, space_id: IdLike) -> SpaceDetail: + """ + Read one space with its full configuration. + + Args: + space_id: The ID of the space to fetch. + + Returns: + A `SpaceDetail` object. This carries `predictor_multiplier`, + `max_media_size`, and the full `enabled_tasks`, none of which appear on the + summary `Space` objects returned by `list`. + + Raises: + GuardNotFoundError: If the space is unknown or you cannot see it. + """ + data = self._transport.request("GET", f"{_BASE}{space_id}") + return SpaceDetail.model_validate(data) + + def iter_all( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + is_public: Optional[bool] = None, + is_default: Optional[bool] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[SpaceOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> Iterator[Space]: + """ + Yield every matching space by fetching pages as needed. + + Args: + user_id: Only spaces owned by this user. + organization_id: Only spaces owned by this organization. + predictor_id: Only spaces using this predictor. + is_public: Filter by public visibility. + is_default: Filter to default spaces. + statuses: Keep only these statuses. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. + + Yields: + Each matching space with the oldest page first. + + Note: + Pages are fetched lazily. Breaking out of the loop early stops + the requests rather than paying for the whole set. + """ + skip = 0 + while True: + page = self.list( + user_id=user_id, + organization_id=organization_id, + predictor_id=predictor_id, + is_public=is_public, + is_default=is_default, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + yield from page.data + + skip += len(page.data) + # a short page means the end; the length check also guarantees termination + # if `count` is stale or wrong + if not page.data or len(page.data) < page_size or skip >= page.count: + return + + +class AsyncSpaces(_SpacesBase): + """ + Asynchronous space endpoints. + + This class mirrors `Spaces` method for method. See the synchronous methods for full + argument details. + """ + + def __init__( + self, + transport: AsyncTransport, + *, + default_organization_id: Optional[IdLike] = None, + ) -> None: + """ + Bind this resource to a transport with an optional default id. + + Args: + transport: The client transport whose connection pool is shared. + default_organization_id: Used when a call omits it. The id can be set once + on the client instead of on every call. + """ + super().__init__(default_organization_id) + self._transport = transport + + async def create( + self, + *, + name: str, + predictor_id: IdLike, + description: Optional[str] = None, + is_public: bool = False, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + enabled_task_ids: Optional[Sequence[IdLike]] = None, + dedicated_runner_ids: Optional[Sequence[IdLike]] = None, + ) -> Space: + """ + Create a space. + + Args: + name: The display name of the space. + predictor_id: The model powering the space. + description: A longer description of the space. + is_public: Whether everyone can see the space. + user_id: The owning user. Mutually exclusive with `organization_id`. + organization_id: The owning organization. + enabled_task_ids: The tasks to enable in the space. + dedicated_runner_ids: Allowed for organization spaces only. + + Returns: + The created space. Review `Spaces.create` for every argument and rule. + + Raises: + GuardError: If a value is invalid or the owner rule was broken. + GuardConflictError: If a space with this name already exists here. + GuardPaymentRequiredError: If there is no active subscription or the space + limit is reached. + """ + payload = self._create_payload( + name=name, + predictor_id=predictor_id, + description=description, + is_public=is_public, + user_id=user_id, + organization_id=organization_id, + enabled_task_ids=enabled_task_ids, + dedicated_runner_ids=dedicated_runner_ids, + ) + # never retried: see the note on Spaces.create + data = await self._transport.request("POST", _BASE, json=payload) + return Space.model_validate(data) + + async def list( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + is_public: Optional[bool] = None, + is_default: Optional[bool] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[SpaceOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> SpacePage: + """ + List the spaces matching the given filters. + + Args: + user_id: Only spaces owned by this user. + organization_id: Only spaces owned by this organization. + predictor_id: Only spaces using this predictor. + is_public: Filter by public visibility. + is_default: Filter to default spaces. + statuses: Keep only these statuses. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + skip: Offset of the results. Must be 0 or greater. + limit: Page size, 1-100. + + Returns: + A `SpacePage`. Review `Spaces.list` for full filter details. + """ + params = self._list_params( + user_id=user_id, + organization_id=organization_id, + predictor_id=predictor_id, + is_public=is_public, + is_default=is_default, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = await self._transport.request("GET", _BASE, params=params) + return SpacePage.model_validate(data) + + async def get(self, space_id: IdLike) -> SpaceDetail: + """ + Read one space with its full configuration. + + Args: + space_id: The ID of the space to fetch. + + Returns: + A `SpaceDetail` object carrying `predictor_multiplier` and the full + `enabled_tasks`. Review `Spaces.get` for more details. + + Raises: + GuardNotFoundError: If the space is unknown or you cannot see it. + """ + data = await self._transport.request("GET", f"{_BASE}{space_id}") + return SpaceDetail.model_validate(data) + + async def iter_all( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + is_public: Optional[bool] = None, + is_default: Optional[bool] = None, + statuses: Optional[Sequence[StatusLike]] = None, + sort_by: Optional[Union[SpaceOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> AsyncIterator[Space]: + """ + Yield every matching space by fetching pages as needed. + + Args: + user_id: Only spaces owned by this user. + organization_id: Only spaces owned by this organization. + predictor_id: Only spaces using this predictor. + is_public: Filter by public visibility. + is_default: Filter to default spaces. + statuses: Keep only these statuses. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. + + Yields: + Each matching space. Review `Spaces.iter_all` for more context. + """ + skip = 0 + while True: + page = await self.list( + user_id=user_id, + organization_id=organization_id, + predictor_id=predictor_id, + is_public=is_public, + is_default=is_default, + statuses=statuses, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + for space in page.data: + yield space + + skip += len(page.data) + if not page.data or len(page.data) < page_size or skip >= page.count: + return diff --git a/src/guard_client/tasks.py b/src/guard_client/tasks.py new file mode 100644 index 0000000..301adee --- /dev/null +++ b/src/guard_client/tasks.py @@ -0,0 +1,275 @@ +""" +Low-level bindings for the `/api/v1/tasks/` endpoints. + +A task is one detection a space can run, such as AI-generated, violence, and so on. +Listing them supplies the `enabled_task_ids` accepted by `Spaces.create`. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Dict, Iterator, Optional, Union + +from .filters import MAX_LIMIT, IdLike, add_ids, add_sort, validate_pagination +from .models import SortOrder, Task, TaskOrder, TaskPage +from .transport import AsyncTransport, SyncTransport + +__all__ = ["AsyncTasks", "Tasks"] + +#: The base URL path for the tasks endpoints. +_BASE = "/api/v1/tasks/" + + +class _TasksBase: + """ + Query and payload construction with no network I/O. + + Everything that does not touch the network lives here. This ensures the synchronous + and asynchronous resources cannot drift in how they build or validate a request. + """ + + @staticmethod + def _list_params( + *, + user_id: Optional[IdLike], + organization_id: Optional[IdLike], + predictor_id: Optional[IdLike], + sort_by: Optional[Union[TaskOrder, str]], + sort_order: Optional[Union[SortOrder, str]], + skip: int, + limit: int, + ) -> Dict[str, Any]: + """ + Build the query parameters for a list request. + + Validating here means a bad filter never reaches the network. The arguments + mirror `list`, but none are optional here. + + Args: + user_id: Only tasks available to this user. + organization_id: Only tasks available to this organization. + predictor_id: Only tasks supported by this predictor. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + skip: Offset of the results. Must be 0 or greater. + limit: Page size, 1-100. + + Returns: + The query dict with every unset filter omitted. + + Raises: + GuardError: If a filter value is invalid. + """ + validate_pagination(skip, limit) + + params: Dict[str, Any] = {"skip": skip, "limit": limit} + # unlike spaces, this route does not reject both owner filters together, so no + # mutual-exclusion check is imposed here + add_ids( + params, + user_id=user_id, + organization_id=organization_id, + predictor_id=predictor_id, + ) + add_sort(params, sort_by, sort_order, TaskOrder) + return params + + +class Tasks(_TasksBase): + """ + Synchronous task endpoints. + + This class is accessed through the client rather than being constructed directly, + and it shares the client connection pool. + """ + + def __init__(self, transport: SyncTransport) -> None: + """ + Bind this resource to a transport. + + Args: + transport: The client transport whose connection pool is shared. + """ + self._transport = transport + + def list( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + sort_by: Optional[Union[TaskOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> TaskPage: + """ + List the available detection tasks. + + Args: + user_id: Only tasks available to this user. + organization_id: Only tasks available to this organization. + predictor_id: Only tasks the given predictor supports. This is the usual + filter when picking `enabled_task_ids` for a new space. + sort_by: Valid options include `"name"` or `"created_at"`. Server default: + `"name"`. + sort_order: `"asc"` or `"desc"`. Server default: `"asc"`. + skip: Offset. 0 or greater. + limit: Page size, 1-100. + + Returns: + A `TaskPage`. You can iterate it like a list or read `.count`. + """ + params = self._list_params( + user_id=user_id, + organization_id=organization_id, + predictor_id=predictor_id, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = self._transport.request("GET", _BASE, params=params) + return TaskPage.model_validate(data) + + def iter_all( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + sort_by: Optional[Union[TaskOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> Iterator[Task]: + """ + Yield every matching task by fetching pages as needed. + + Args: + user_id: Only tasks available to this user. + organization_id: Only tasks available to this organization. + predictor_id: Only tasks supported by this predictor. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. + + Yields: + Each matching task with the oldest page first. + + Note: + Pages are fetched lazily. Breaking out of the loop early stops the requests + rather than paying for the whole set. + """ + skip = 0 + while True: + page = self.list( + user_id=user_id, + organization_id=organization_id, + predictor_id=predictor_id, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + yield from page.data + + skip += len(page.data) + if not page.data or len(page.data) < page_size or skip >= page.count: + return + + +class AsyncTasks(_TasksBase): + """ + Asynchronous task endpoints. + + This class mirrors `Tasks` method for method. See the synchronous methods for full + argument details. + """ + + def __init__(self, transport: AsyncTransport) -> None: + """ + Bind this resource to a transport. + + Args: + transport: The client transport whose connection pool is shared. + """ + self._transport = transport + + async def list( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + sort_by: Optional[Union[TaskOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + skip: int = 0, + limit: int = MAX_LIMIT, + ) -> TaskPage: + """ + List the tasks matching the given filters. + + Args: + user_id: Only tasks available to this user. + organization_id: Only tasks available to this organization. + predictor_id: Only tasks supported by this predictor. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + skip: Offset. 0 or greater. + limit: Page size, 1-100. + + Returns: + A `TaskPage`. Review `Tasks.list` for full filter details. + """ + params = self._list_params( + user_id=user_id, + organization_id=organization_id, + predictor_id=predictor_id, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=limit, + ) + data = await self._transport.request("GET", _BASE, params=params) + return TaskPage.model_validate(data) + + async def iter_all( + self, + *, + user_id: Optional[IdLike] = None, + organization_id: Optional[IdLike] = None, + predictor_id: Optional[IdLike] = None, + sort_by: Optional[Union[TaskOrder, str]] = None, + sort_order: Optional[Union[SortOrder, str]] = None, + page_size: int = MAX_LIMIT, + ) -> AsyncIterator[Task]: + """ + Yield every matching task by fetching pages as needed. + + Args: + user_id: Only tasks available to this user. + organization_id: Only tasks available to this organization. + predictor_id: Only tasks supported by this predictor. + sort_by: The field to sort the results by. + sort_order: The direction to sort the results. + page_size: The number of items to fetch per page. Defaults to `MAX_LIMIT`. + + Yields: + Each matching task. Review `Tasks.iter_all` for more context. + """ + skip = 0 + while True: + page = await self.list( + user_id=user_id, + organization_id=organization_id, + predictor_id=predictor_id, + sort_by=sort_by, + sort_order=sort_order, + skip=skip, + limit=page_size, + ) + for task in page.data: + yield task + + skip += len(page.data) + if not page.data or len(page.data) < page_size or skip >= page.count: + return diff --git a/src/guard_client/tokens.py b/src/guard_client/tokens.py new file mode 100644 index 0000000..0f45850 --- /dev/null +++ b/src/guard_client/tokens.py @@ -0,0 +1,234 @@ +""" +Estimates what an activity will cost before creating it. + +The calculation comes down to the following formula: + `tokens = frames x resolution_cost x model_multiplier` + +Frames are calculated as one per second of video, rounded up, with a minimum of 1. A +still image is 1 frame and a 10.4 second clip is 11 frames. Resolution cost is a tier +taken from the long side of the media, which keeps the price independent of orientation. +The model multiplier is the `predictor_multiplier` of the space. + +| Tier | `max(w, h)` | Cost | +| ---- | ----------- | ---- | +| 1 | <= 1920 | 1x | +| 2 | <= 2560 | 2x | +| 3 | <= 3840 | 4x | +| None | > 3840 | raises an error | + +`GuardClient.estimate_tokens` reads the dimensions from the file and looks the +multiplier up for you. The functions here are the pure calculations underneath. + +Examples: + ```python + frames_for(10.4) # returns 11 + + # portrait dimensions result in the same price as landscape + tier_for(1080, 1920) # returns (1, 1) + + est = estimate_tokens(frames=11, width=2560, height=1440, multiplier=4) + print(est.tokens) # returns 88 + ``` + +Warning: + This is an estimate. The API currently reserves only the minimum possible cost when + an activity is created, and the authoritative figure is `payed_tokens` on the + finished activity. Expect the two to differ. + +Note: + There are two deliberate consequences of pricing by the long side. An ultrawide + 2560x1080 video costs 2x despite having fewer pixels than 1080p. Additionally, DCI + 4K (4096x2160) raises an error because its long side exceeds 3840, whereas standard + UHD 3840x2160 is accepted. +""" + +from __future__ import annotations + +import math +from typing import Tuple + +from pydantic import BaseModel, ConfigDict, Field + +from .exceptions import GuardError + +__all__ = [ + "MAX_LONG_SIDE", + "RESOLUTION_TIERS", + "TokenEstimate", + "estimate_tokens", + "frames_for", + "tier_for", +] + +#: Maps `(max long side, cost)` per tier, starting with the cheapest first. The long +#: side is calculated as `max(width, height)`. This keeps the rule +#: orientation-independent so a portrait 1080x1920 and a landscape 1920x1080 cost the +#: same. It also makes the calculation insensitive to MP4 rotation metadata, which only +#: swaps the two dimensions. +RESOLUTION_TIERS: Tuple[Tuple[int, int], ...] = ( + (1920, 1), # tier 1, up to 1080p + (2560, 2), # tier 2, up to 1440p + (3840, 4), # tier 3, up to 4K UHD +) + +#: The largest long side that can be priced. Beyond this limit, there is no defined +#: tier. +MAX_LONG_SIDE = RESOLUTION_TIERS[-1][0] + + +class TokenEstimate(BaseModel): + """ + The projected cost of one activity, along with the inputs that produced it. + + Carrying the breakdown alongside the total lets a caller explain the number rather + than just report it. + + Attributes: + tokens: The estimate, calculated as `frames * tier_cost * multiplier`. + frames: Billable frames, one per second of video rounded up, minimum 1. + resolution_tier: Which tier the media fell into, from 1 to 3. + tier_cost: The multiplier that tier contributes (1, 2, or 4). + multiplier: The `predictor_multiplier` of the space. + width: Media width in pixels. + height: Media height in pixels. + duration_seconds: Source duration. Zero for a still image. + + Examples: + ```python + est = estimate_tokens(frames=11, width=2560, height=1440, multiplier=4) + print(est.tokens) # 88 + print(est) # 88 tokens (11 frames x 2 x 4) + ``` + """ + + model_config = ConfigDict(extra="ignore") + + tokens: int = Field(description="frames x tier_cost x multiplier") + frames: int + resolution_tier: int = Field(ge=1, le=3) + tier_cost: int + multiplier: int + width: int + height: int + duration_seconds: float = 0.0 + + def __str__(self) -> str: + """ + Render the total with the factors that produced it. + + Returns: + A formatted string like `88 tokens (11 frames x 2 x 4)`. Printing an + estimate shows exactly why it is what it is. + """ + return ( + f"{self.tokens} tokens " + f"({self.frames} frames x {self.tier_cost} x {self.multiplier})" + ) + + +def frames_for(duration_seconds: float) -> int: + """ + Calculate the billable frames for a given duration. + + The calculation is one frame per second of video, rounded up, with a minimum of 1. + A still image has no duration and bills a single frame. A 10.4 second clip bills 11 + frames because a partial second is still processed. + + Args: + duration_seconds: The length of the media in seconds. + + Returns: + The calculated number of billable frames. + + Raises: + GuardError: If `duration_seconds` is negative. + """ + if duration_seconds < 0: + raise GuardError( + f"Invalid duration_seconds={duration_seconds}. Expected 0 or greater" + ) + return max(1, math.ceil(duration_seconds)) + + +def tier_for(width: int, height: int) -> Tuple[int, int]: + """ + Resolve pixel dimensions to a tier and cost pair. + + The tier is chosen by the long side of the media. This ensures a rotated video lands + in the same tier either way. + + Args: + width: The width of the media in pixels. + height: The height of the media in pixels. + + Returns: + A tuple of `(tier, cost)` based on the defined resolution tiers. + + Raises: + GuardError: If a dimension is not positive, or if the long side exceeds + `MAX_LONG_SIDE`. Beyond the top tier there is no defined price, and guessing + one would misstate the cost. + """ + if width <= 0 or height <= 0: + raise GuardError( + f"Invalid dimensions {width}x{height}. Both sides must be positive" + ) + + long_side = max(width, height) + for tier, (limit, cost) in enumerate(RESOLUTION_TIERS, start=1): + if long_side <= limit: + return tier, cost + + raise GuardError( + f"Cannot estimate {width}x{height}: its long side ({long_side}) exceeds the " + f"largest tier ({MAX_LONG_SIDE}). Note this rejects DCI 4K (4096x2160) even " + f"though UHD (3840x2160) is fine." + ) + + +def estimate_tokens( + *, + frames: int, + width: int, + height: int, + multiplier: int, + duration_seconds: float = 0.0, +) -> TokenEstimate: + """ + Apply the cost formula to already-known values. + + This is the pure calculation without any file access or network requests. Use + `GuardClient.estimate_tokens` to have the media probed and the multiplier looked up + for you automatically. + + Args: + frames: Billable frames, typically derived from `frames_for`. + width: Media width in pixels. + height: Media height in pixels. + multiplier: The `predictor_multiplier` of the space. + duration_seconds: Carried through onto the result for display purposes only. + Defaults to 0.0. + + Returns: + A `TokenEstimate` object containing the total cost and the breakdown. + + Raises: + GuardError: If any input is out of range, or if the resolution exceeds the + maximum tier. + """ + if frames < 1: + raise GuardError(f"Invalid frames={frames}. Expected 1 or greater") + if multiplier < 1: + raise GuardError(f"Invalid multiplier={multiplier}. Expected 1 or greater") + + tier, cost = tier_for(width, height) + return TokenEstimate( + tokens=frames * cost * multiplier, + frames=frames, + resolution_tier=tier, + tier_cost=cost, + multiplier=multiplier, + width=width, + height=height, + duration_seconds=duration_seconds, + ) diff --git a/src/guard_client/transport.py b/src/guard_client/transport.py index 491baaf..88f0966 100644 --- a/src/guard_client/transport.py +++ b/src/guard_client/transport.py @@ -1 +1,644 @@ -# TODO: Internal HTTP logic (handling requests, retries, headers) \ No newline at end of file +""" +Internal HTTP transport handling authentication, locale, error mapping, and retries. + +Two thin wrappers over `httpx`, `SyncTransport` and `AsyncTransport`, share their +configuration and all non-I/O logic via `_TransportBase`. +""" + +from __future__ import annotations + +import asyncio +import random +import time +from dataclasses import dataclass, field +from typing import Any, Dict, Mapping, Optional, Tuple + +import httpx + +from .exceptions import ( + GuardAPIError, + GuardAuthError, + GuardConflictError, + GuardConnectionError, + GuardError, + GuardNotFoundError, + GuardPaymentRequiredError, + GuardRateLimitError, + GuardServerError, + GuardUploadError, + GuardValidationError, +) + +__all__ = ["AsyncTransport", "SyncTransport", "TransportConfig", "DEFAULT_BASE_URL"] + +#: The Guard API every client talks to unless `base_url` or `GUARD_BASE_URL` says +#: otherwise. +DEFAULT_BASE_URL = "https://api.elhio.com" + +#: Methods replayed by default. Individual calls override this with `retry`. +#: `POST /activities/` opts in because a duplicate create only leaves an unused +#: activity, while `DELETE /runners/{id}` opts out because it tears down a live +#: deployment. +_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) + +#: HTTP status codes that indicate a request should be retried. +_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504}) + +#: The maximum number of seconds to wait before replaying a failed request. +_MAX_BACKOFF = 30.0 + + +@dataclass +class TransportConfig: + """ + Everything both transports need to construct a request. + + This is a plain data holder. The client resolves each value from arguments, the + environment, and `.env` before building one of these, so nothing here consults its + surroundings. + + Attributes: + api_key: Bearer token sent on every API request. `None` is allowed only for + local-only use. Any API request made without one raises `GuardError` rather + than going out unauthenticated. Presigned uploads and media downloads are + unaffected. + base_url: API root, without a trailing slash. + locale: Language for server-rendered labels, sent as `lang`. + timeout: Per-request HTTP timeout in seconds. + max_retries: How many times a retryable request may be replayed. + headers: Extra headers merged into every request. + """ + + api_key: Optional[str] = None + base_url: str = DEFAULT_BASE_URL + locale: str = "en" + timeout: float = 30.0 + max_retries: int = 3 + headers: Dict[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + """ + Normalize the base URL. + + This strips any trailing slash so joining a path onto it cannot produce a double + slash that the server would treat as a different route. + """ + self.base_url = self.base_url.rstrip("/") + + +def _extract_detail(response: httpx.Response) -> Tuple[str, Any]: + """ + Pull a human-readable message out of a failed response. + + It prefers the API's `detail` field and falls back to the status line. Note that 422 + errors return `detail` as a list of field errors. + + Args: + response: The failed HTTP response. + + Returns: + A tuple containing a human-readable error message and the raw detail payload. + """ + try: + payload = response.json() + except (ValueError, UnicodeDecodeError): + return f"API Error: {response.status_code} {response.reason_phrase}", None + + if not isinstance(payload, dict): + return f"API Error: {response.status_code} {response.reason_phrase}", payload + + detail = payload.get("detail") + if isinstance(detail, str) and detail.strip(): + return detail, detail + if isinstance(detail, list) and detail: + parts = [] + for entry in detail: + if isinstance(entry, dict): + loc = ".".join(str(p) for p in entry.get("loc", []) if p != "body") + msg = entry.get("msg", "invalid") + parts.append(f"{loc}: {msg}" if loc else str(msg)) + else: + parts.append(str(entry)) + return "; ".join(parts), detail + + return f"API Error: {response.status_code} {response.reason_phrase}", detail + + +def _raise_for_status(response: httpx.Response) -> None: + """ + Map a non-2xx response onto the exception tree. + + Args: + response: The response to inspect. + + Raises: + GuardAuthError: On 401 or 403 status. + GuardPaymentRequiredError: On 402 status. + GuardNotFoundError: On 404 status. + GuardConflictError: On 409 status. + GuardValidationError: On 422 status. + GuardRateLimitError: On 429 status. + GuardServerError: On any 5xx status. + GuardAPIError: On any other non-2xx status. + """ + if response.is_success: + return + + message, detail = _extract_detail(response) + status = response.status_code + request_id = response.headers.get("x-request-id") + kwargs: Dict[str, Any] = { + "status_code": status, + "detail": detail, + "request_id": request_id, + } + + if status in (401, 403): + raise GuardAuthError(message, **kwargs) + if status == 402: + raise GuardPaymentRequiredError(message, **kwargs) + if status == 404: + raise GuardNotFoundError(message, **kwargs) + if status == 409: + raise GuardConflictError(message, **kwargs) + if status == 422: + raise GuardValidationError(message, **kwargs) + if status == 429: + retry_after = response.headers.get("retry-after") + raise GuardRateLimitError( + message, + retry_after=float(retry_after) + if retry_after and retry_after.isdigit() + else None, + **kwargs, + ) + if status >= 500: + raise GuardServerError(message, **kwargs) + raise GuardAPIError(message, **kwargs) + + +def _backoff_delay(attempt: int, response: Optional[httpx.Response]) -> float: + """ + Determine how long to wait before replaying a request. + + Args: + attempt: Zero-based attempt number so the delay doubles each time. + response: The failed response, consulted for the `Retry-After` header. + + Returns: + Seconds to sleep, capped at the defined maximum backoff. + + Note: + Full jitter spreads retries out so a fleet recovering from an outage does not + stampede the API in lockstep. + """ + if response is not None: + retry_after = response.headers.get("retry-after") + if retry_after and retry_after.isdigit(): + return min(float(retry_after), _MAX_BACKOFF) + return min(2.0**attempt, _MAX_BACKOFF) * (0.5 + random.random() / 2) + + +class _TransportBase: + """ + Request construction and retry bookkeeping shared by both transports. + + This holds everything that does not perform I/O so the sync and async transports + cannot drift in how they build a request or decide to retry it. + """ + + def __init__(self, config: TransportConfig) -> None: + """ + Store the resolved configuration. + + Args: + config: Fully resolved settings. Nothing here reads the environment. + """ + self._config = config + + @property + def config(self) -> TransportConfig: + """ + The configuration this transport was built with. + + Returns: + The live config object, not a copy. + """ + return self._config + + def _build_headers(self) -> Dict[str, str]: + """ + Build the headers every API request carries. + + Returns: + A dictionary containing `Accept`, any configured extras, and the bearer + token. + + Raises: + GuardError: If no API key was configured. Every API request must be + authenticated, so this refuses before the request is sent rather than + letting an anonymous one through. + + Note: + This is only used for API requests. Presigned uploads and media downloads + deliberately bypass this so the key never reaches a third-party host. This + is also why they keep working on a keyless client. + """ + if not self._config.api_key: + raise GuardError( + "An API key is required for cloud requests. Pass api_key=..., set the " + "GUARD_API_KEY environment variable, or put it in a .env file (see " + ".env.example). This client was built without one, so only " + 'engine="local" detection is available.' + ) + headers = {"Accept": "application/json", **self._config.headers} + headers["Authorization"] = f"Bearer {self._config.api_key}" + return headers + + def _build_params(self, params: Optional[Mapping[str, Any]]) -> Dict[str, Any]: + """ + Merge the caller-supplied query parameters with the locale. + + Args: + params: Caller-supplied parameters. Entries that are `None` are dropped so + an unset filter is omitted rather than sent as a null. + + Returns: + The query dict to send, always carrying `lang`. + """ + # API localises result labels/descriptions off `lang` + merged: Dict[str, Any] = {"lang": self._config.locale} + for key, value in (params or {}).items(): + if value is not None: + merged[key] = value + return merged + + def _should_retry( + self, + *, + attempt: int, + method: str, + retry: Optional[bool], + response: Optional[httpx.Response], + ) -> bool: + """ + Decide whether to replay a request. + + Args: + attempt: The current zero-based attempt number. + method: The HTTP method used for the request. + retry: Overrides the method-based default. `True` opts a non-idempotent call + in, `False` opts a nominally-idempotent one out, and `None` leaves the + decision to the method. + response: The failed response, or `None` for a transport-level failure. + + Returns: + `True` if the request should be retried, `False` otherwise. + """ + if attempt >= self._config.max_retries: + return False + if retry is False: + return False + if retry is not True and method.upper() not in _IDEMPOTENT_METHODS: + return False + if response is None: # transport-level failure + return True + return response.status_code in _RETRY_STATUSES + + +class SyncTransport(_TransportBase): + """ + Blocking HTTP transport. + + This owns an `httpx.Client` unless one is supplied, in which case closing is left + to whoever provided it. + """ + + def __init__( + self, config: TransportConfig, *, http_client: Optional[httpx.Client] = None + ) -> None: + """ + Build a transport, creating a connection pool unless given one. + + Args: + config: Fully resolved settings. + http_client: An existing client to borrow. When supplied, `close` leaves it + open since its lifetime belongs to the caller. + """ + super().__init__(config) + self._owns_client = http_client is None + self._client = http_client or httpx.Client(timeout=config.timeout) + + def request( + self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json: Any = None, + data: Any = None, + headers: Optional[Mapping[str, str]] = None, + retry: Optional[bool] = None, + ) -> Any: + """ + Issue an authenticated request and return the decoded JSON body. + + Args: + method: HTTP method. + path: Path below `base_url`, starting with a slash. + params: Query parameters. `None` values are dropped. + json: JSON body to send. + data: Form body to send. + headers: Extra headers for this request only. + retry: Overrides the method-based retry policy. `True` opts a non-idempotent + call in, `False` opts a nominally-idempotent one out. + + Returns: + The decoded JSON body, or `None` for an empty response. + + Raises: + GuardAPIError: If the API returned a non-2xx status. Which subclass depends + on the status code. + GuardConnectionError: If the request never reached the API. + """ + url = f"{self._config.base_url}{path}" + merged_headers = {**self._build_headers(), **(headers or {})} + merged_params = self._build_params(params) + last_exc: Optional[Exception] = None + + for attempt in range(self._config.max_retries + 1): + response: Optional[httpx.Response] = None + try: + response = self._client.request( + method, + url, + params=merged_params, + json=json, + data=data, + headers=merged_headers, + ) + except httpx.HTTPError as exc: + last_exc = exc + if not self._should_retry( + attempt=attempt, method=method, retry=retry, response=None + ): + raise GuardConnectionError( + f"Request to {url} failed: {exc}" + ) from exc + time.sleep(_backoff_delay(attempt, None)) + continue + + if self._should_retry( + attempt=attempt, method=method, retry=retry, response=response + ): + time.sleep(_backoff_delay(attempt, response)) + continue + + _raise_for_status(response) + return _decode(response) + + raise GuardConnectionError(f"Request to {url} failed after retries: {last_exc}") + + def upload( + self, url: str, fields: Mapping[str, str], filename: str, data: bytes + ) -> None: + """ + POST bytes to a presigned storage target. + + Args: + url: The presigned endpoint from `upload_data.url`. + fields: Policy fields from `upload_data.fields`. + filename: Name for the multipart file part. + data: The media bytes. + + Raises: + GuardUploadError: If storage rejected the upload or could not be reached. + + Note: + This deliberately bypasses `request`. The presigned policy must not receive + our `Authorization` header because that would hand the API key to a + third-party host, nor the `lang` parameter, and the policy fields have to + precede the file part in the multipart body. + """ + try: + response = self._client.post( + url, + data=dict(fields), + files={"file": (filename, data)}, + ) + except httpx.HTTPError as exc: + raise GuardUploadError(f"Media upload to {url} failed: {exc}") from exc + + if not response.is_success: + raise GuardUploadError( + f"Media upload failed: {response.status_code} {response.reason_phrase}", + status_code=response.status_code, + ) + + def close(self) -> None: + """ + Release the connection pool if this transport owns it. + + A borrowed client is left open since its lifetime belongs to whoever passed it. + """ + if self._owns_client: + self._client.close() + + def __enter__(self) -> SyncTransport: + """ + Enter a context manager. + + Returns: + This transport, unchanged. + """ + return self + + def __exit__(self, *exc_info: Any) -> None: + """ + Leave a context manager, closing the pool. + + Args: + *exc_info: Exception details, ignored because closing happens either way. + """ + self.close() + + +class AsyncTransport(_TransportBase): + """ + Non-blocking HTTP transport. + + This mirrors `SyncTransport` method for method, sharing all request construction and + retry logic through `_TransportBase`. + """ + + def __init__( + self, + config: TransportConfig, + *, + http_client: Optional[httpx.AsyncClient] = None, + ) -> None: + """ + Build a transport, creating a connection pool unless given one. + + Args: + config: Fully resolved settings. + http_client: An existing client to borrow. When supplied, `aclose` leaves it + open. + """ + super().__init__(config) + self._owns_client = http_client is None + self._client = http_client or httpx.AsyncClient(timeout=config.timeout) + + async def request( + self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json: Any = None, + data: Any = None, + headers: Optional[Mapping[str, str]] = None, + retry: Optional[bool] = None, + ) -> Any: + """ + Issue an authenticated request and return the decoded JSON body. + + Args: + method: HTTP method. + path: Path below `base_url`, starting with a slash. + params: Query parameters. `None` values are dropped. + json: JSON body to send. + data: Form body to send. + headers: Extra headers for this request only. + retry: Overrides the method-based retry policy. + + Returns: + The decoded JSON body, or `None` for an empty response. + + Raises: + GuardAPIError: If the API returned a non-2xx status. + GuardConnectionError: If the request never reached the API. + """ + url = f"{self._config.base_url}{path}" + merged_headers = {**self._build_headers(), **(headers or {})} + merged_params = self._build_params(params) + last_exc: Optional[Exception] = None + + for attempt in range(self._config.max_retries + 1): + response: Optional[httpx.Response] = None + try: + response = await self._client.request( + method, + url, + params=merged_params, + json=json, + data=data, + headers=merged_headers, + ) + except httpx.HTTPError as exc: + last_exc = exc + if not self._should_retry( + attempt=attempt, method=method, retry=retry, response=None + ): + raise GuardConnectionError( + f"Request to {url} failed: {exc}" + ) from exc + await asyncio.sleep(_backoff_delay(attempt, None)) + continue + + if self._should_retry( + attempt=attempt, method=method, retry=retry, response=response + ): + await asyncio.sleep(_backoff_delay(attempt, response)) + continue + + _raise_for_status(response) + return _decode(response) + + raise GuardConnectionError(f"Request to {url} failed after retries: {last_exc}") + + async def upload( + self, url: str, fields: Mapping[str, str], filename: str, data: bytes + ) -> None: + """ + POST bytes to a presigned storage target. + + Args: + url: The presigned endpoint from `upload_data.url`. + fields: Policy fields from `upload_data.fields`. + filename: Name for the multipart file part. + data: The media bytes. + + Raises: + GuardUploadError: If storage rejected the upload or could not be reached. + + Note: + This carries no `Authorization` header for the reason given on + `SyncTransport.upload`. + """ + try: + response = await self._client.post( + url, + data=dict(fields), + files={"file": (filename, data)}, + ) + except httpx.HTTPError as exc: + raise GuardUploadError(f"Media upload to {url} failed: {exc}") from exc + + if not response.is_success: + raise GuardUploadError( + f"Media upload failed: {response.status_code} {response.reason_phrase}", + status_code=response.status_code, + ) + + async def aclose(self) -> None: + """ + Release the connection pool if this transport owns it. + + A borrowed client is left open since its lifetime belongs to whoever passed it. + """ + if self._owns_client: + await self._client.aclose() + + async def __aenter__(self) -> AsyncTransport: + """ + Enter an async context manager. + + Returns: + This transport, unchanged. + """ + return self + + async def __aexit__(self, *exc_info: Any) -> None: + """ + Leave an async context manager, closing the pool. + + Args: + *exc_info: Exception details, ignored because closing happens either way. + """ + await self.aclose() + + +def _decode(response: httpx.Response) -> Any: + """ + Decode a successful response body. + + Args: + response: A response that already passed `_raise_for_status`. + + Returns: + The parsed JSON, or `None` for a 204 status or an empty body. + + Raises: + GuardAPIError: If the body was not JSON despite a success status. + """ + if response.status_code == 204 or not response.content: + return None + try: + return response.json() + except ValueError as exc: + raise GuardAPIError( + f"Expected a JSON response but got " + f"{response.headers.get('content-type')!r}", + status_code=response.status_code, + ) from exc diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4d16655 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,364 @@ +""" +Shared fixtures and payload builders for the test suite. +""" + +from __future__ import annotations + +import importlib.util +import os +import struct +import zlib +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional +from uuid import UUID, uuid4 + +import pytest + +# `test_contract.py` is shared byte-identically with ../guard-local-python and does a +# bare `import guard_local` at module scope, so without the [local] extra it fails at +# collection rather than skipping. The guard has to live out here: editing the file to +# add one would defeat the point of keeping the two copies identical. +collect_ignore = [] +if importlib.util.find_spec("guard_local") is None: + collect_ignore.append("test_contract.py") + +BASE_URL = "https://api.test.invalid" +API_KEY = "test-key" +SPACE_ID = UUID("11111111-1111-1111-1111-111111111111") +ACTIVITY_ID = UUID("22222222-2222-2222-2222-222222222222") +TASK_ID = UUID("33333333-3333-3333-3333-333333333333") +ORG_ID = UUID("44444444-4444-4444-4444-444444444444") +PREDICTOR_ID = UUID("55555555-5555-5555-5555-555555555555") +USER_ID = UUID("77777777-7777-7777-7777-777777777777") +RUNNER_ID = UUID("88888888-8888-8888-8888-888888888888") +REACTION_ID = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") +SHARE_ID = UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") +UPLOAD_URL = "https://s3.test.invalid/bucket" + + +def png_bytes() -> bytes: + """A real 1x1 PNG, so magic-byte sniffing has something valid to work with.""" + + def chunk(tag: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) + idat = zlib.compress(b"\x00\x00\x00\x00") + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", idat) + + chunk(b"IEND", b"") + ) + + +def jpeg_bytes() -> bytes: + """Generate basic JPEG magic bytes for testing.""" + return b"\xff\xd8\xff\xe0" + b"\x00" * 16 + + +def _now() -> str: + """Return the current UTC time as an ISO-8601 string.""" + return datetime.now(timezone.utc).isoformat() + + +def create_response(**overrides: Any) -> Dict[str, Any]: + """Build a mock response for an activity creation request.""" + payload: Dict[str, Any] = { + "id": str(ACTIVITY_ID), + "status": "pending_upload", + "created_at": _now(), + "space_id": str(SPACE_ID), + "user_id": None, + "account_id": None, + "guest_id": None, + "space_name": "Test Space", + "user_name": None, + "account_name": None, + "media_type": "image/png", + "upload_data": { + "url": UPLOAD_URL, + "fields": { + "key": "uploads/abc", + "policy": "xyz", + "Content-Type": "image/png", + }, + }, + } + payload.update(overrides) + return payload + + +def status_response(status: str = "completed", **overrides: Any) -> Dict[str, Any]: + """Build a mock status response for an activity.""" + payload: Dict[str, Any] = { + "id": str(ACTIVITY_ID), + "status": status, + "user_id": None, + "account_id": None, + "guest_id": None, + } + payload.update(overrides) + return payload + + +def detail_response( + status: str = "completed", + results: Optional[List[Dict[str, Any]]] = None, + **overrides: Any, +) -> Dict[str, Any]: + """Build a mock detailed response for an activity.""" + if results is None: + results = [ + { + "task_id": str(TASK_ID), + "score": 87, + "label": "Deepfake", + "description": "Likely synthetic", + "media_url": None, + } + ] + payload: Dict[str, Any] = { + "id": str(ACTIVITY_ID), + "status": status, + "created_at": _now(), + "updated_at": _now(), + "space_id": str(SPACE_ID), + "predictor_id": str(uuid4()), + "runner_id": None, + "user_id": None, + "account_id": None, + "guest_id": None, + "media_type": "image/png", + "media_size": 1024, + "payed_tokens": 1, + "result_payload": {"results": results}, + "space_name": "Test Space", + "predictor_name": "default", + "runner_name": None, + "user_name": None, + "account_name": None, + } + payload.update(overrides) + return payload + + +def space_response(**overrides: Any) -> Dict[str, Any]: + """Build a mock response for a space.""" + payload: Dict[str, Any] = { + "id": str(SPACE_ID), + "status": "active", + "created_at": _now(), + "name": "Test Space", + "description": "A space for testing", + "slug": "test-space", + "url_id": "abc123", + "is_default": True, + "is_public": False, + "user_id": None, + "user_name": None, + "organization_id": str(ORG_ID), + "organization_name": "Test Org", + "predictor_id": str(PREDICTOR_ID), + "predictor_name": "default", + "enabled_media": ["image", "video"], + "enabled_task_names": ["Deepfake", "Violence"], + } + payload.update(overrides) + return payload + + +def spaces_page_response( + count: Optional[int] = None, items: Optional[List[Dict[str, Any]]] = None +) -> Dict[str, Any]: + """A SpacesPublic envelope. Defaults to a single space with count=1.""" + data = [space_response()] if items is None else items + return {"data": data, "count": len(data) if count is None else count} + + +def predictor_response(**overrides: Any) -> Dict[str, Any]: + """Build a mock response for a predictor.""" + payload: Dict[str, Any] = { + "id": str(PREDICTOR_ID), + "name": "Default Predictor", + "status": "active", + "description": "The standard detection model", + "token_multiplier": 1, + "slug": "default-predictor", + "url_id": "pred123", + "supported_media": ["image", "video"], + "supported_task_ids": [str(TASK_ID)], + } + payload.update(overrides) + return payload + + +def task_response(**overrides: Any) -> Dict[str, Any]: + """Build a mock response for a task.""" + payload: Dict[str, Any] = { + "id": str(TASK_ID), + "status": "active", + "name": "Deepfake", + "description": "Detects synthetic media", + "reactions": {"1": "Real photo", "2": "AI generated"}, + } + payload.update(overrides) + return payload + + +def reaction_response(**overrides: Any) -> Dict[str, Any]: + """Build a mock response for a reaction.""" + payload: Dict[str, Any] = { + "id": str(REACTION_ID), + "created_at": _now(), + "activity_id": str(ACTIVITY_ID), + "task_id": str(TASK_ID), + "is_positive": True, + "key_value": None, + "description": None, + } + payload.update(overrides) + return payload + + +def runner_response(**overrides: Any) -> Dict[str, Any]: + """Build a mock response for a runner.""" + payload: Dict[str, Any] = { + "id": str(RUNNER_ID), + "status": "running", + "created_at": _now(), + "terminated_at": None, + "name": "runner-1", + "slug": "runner-1", + "url_id": "run123", + "predictor_id": str(PREDICTOR_ID), + "predictor_name": "Default Predictor", + "organization_id": str(ORG_ID), + "organization_name": "Test Org", + } + payload.update(overrides) + return payload + + +def share_response(**overrides: Any) -> Dict[str, Any]: + """Build a mock response for a share.""" + payload: Dict[str, Any] = { + "id": str(SHARE_ID), + "created_at": _now(), + "expired_at": (datetime.now(timezone.utc) + timedelta(days=7)).isoformat(), + "activity_id": str(ACTIVITY_ID), + "task_id": str(TASK_ID), + "expires_in": 7, + "share_url": "https://elhio.com/s/abc123", + "media_url": "https://cdn.elhio.com/media/abc.jpg", + "task_name": "Deepfake", + "space_name": "Test Space", + "result": { + "task_id": str(TASK_ID), + "score": 87, + "label": "Deepfake", + "description": "Likely synthetic", + "media_url": None, + }, + } + payload.update(overrides) + return payload + + +def space_detail_response(**overrides: Any) -> Dict[str, Any]: + """Build a mock detailed response for a space.""" + payload: Dict[str, Any] = { + "id": str(SPACE_ID), + "status": "active", + "created_at": _now(), + "name": "Test Space", + "description": "A space for testing", + "slug": "test-space", + "url_id": "abc123", + "is_default": True, + "is_public": False, + "user_id": None, + "user_name": None, + "organization_id": str(ORG_ID), + "organization_name": "Test Org", + "predictor_id": str(PREDICTOR_ID), + "predictor_name": "default", + "predictor_multiplier": 3, + "max_media_size": 52428800, + "enabled_media": ["image", "video"], + "enabled_tasks": [task_response()], + "task_thresholds": [ + {"task_id": str(TASK_ID), "blur_threshold": 50, "hide_threshold": 80} + ], + } + payload.update(overrides) + return payload + + +def page_response( + items: List[Dict[str, Any]], count: Optional[int] = None +) -> Dict[str, Any]: + """The {data, count} envelope every list endpoint returns.""" + return {"data": items, "count": len(items) if count is None else count} + + +def confirm_response(**overrides: Any) -> Dict[str, Any]: + """Build a mock response for confirming an activity.""" + payload = create_response() + payload.pop("upload_data") + payload["status"] = "processing" + payload.update(overrides) + return payload + + +@pytest.fixture(autouse=True) +def isolate_env(tmp_path_factory, monkeypatch): + """ + Insulate every test from the developer's real environment. + + Two hazards this closes: + + * exported ``GUARD_*`` variables in the shell running pytest, and + * a real ``.env`` in the repo — ``find_dotenv`` walks *up* from the cwd, so once a + developer creates one for the smoke script it would otherwise leak into the suite. + + Tests that want a ``.env`` write one into the cwd this provides. + """ + for key in [k for k in os.environ if k.startswith("GUARD_")]: + monkeypatch.delenv(key, raising=False) + monkeypatch.chdir(tmp_path_factory.mktemp("cwd")) + + +@pytest.fixture +def png(tmp_path): + """A PNG file on disk, returned as a Path.""" + path = tmp_path / "sample.png" + path.write_bytes(png_bytes()) + return path + + +@pytest.fixture +def client(isolate_env): + """A sync client pointed at the mock base URL, with retries off for speed.""" + from guard_client import GuardClient + + with GuardClient( + api_key=API_KEY, space_id=SPACE_ID, base_url=BASE_URL, max_retries=0 + ) as c: + yield c + + +@pytest.fixture +async def async_client(isolate_env): + """An async client pointed at the mock base URL.""" + from guard_client import AsyncGuardClient + + async with AsyncGuardClient( + api_key=API_KEY, space_id=SPACE_ID, base_url=BASE_URL, max_retries=0 + ) as c: + yield c diff --git a/tests/test_activities.py b/tests/test_activities.py new file mode 100644 index 0000000..1397c8a --- /dev/null +++ b/tests/test_activities.py @@ -0,0 +1,518 @@ +""" +Tests for the low-level activity bindings, sync and async. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +import httpx +import pytest +import respx + +from guard_client import ( + MAX_HISTORY, + ActivityFailedError, + ActivityStatus, + GuardError, + GuardTimeoutError, + MediaType, +) + +from .conftest import ( + ACTIVITY_ID, + BASE_URL, + SPACE_ID, + UPLOAD_URL, + confirm_response, + create_response, + detail_response, + png_bytes, + status_response, +) + +ACTIVITIES_URL = f"{BASE_URL}/api/v1/activities/" + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch): + """Polling tests should not actually wait.""" + monkeypatch.setattr("guard_client.activities.time.sleep", lambda _: None) + + async def _async_sleep(_): + return None + + monkeypatch.setattr("guard_client.activities.asyncio.sleep", _async_sleep) + + +@respx.mock +def test_create_sends_expected_payload(client): + """Verify that creating an activity sends the correct JSON payload to the API.""" + route = respx.post(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json=create_response()) + ) + + activity = client.activities.create(media_type=MediaType.PNG, media_size=1024) + + body = route.calls.last.request.read() + assert b'"space_id":"11111111-1111-1111-1111-111111111111"' in body.replace( + b" ", b"" + ) + assert b'"media_type":"image/png"' in body.replace(b" ", b"") + assert activity.id == ACTIVITY_ID + assert activity.status is ActivityStatus.PENDING_UPLOAD + assert activity.upload_data.url == UPLOAD_URL + + +@respx.mock +def test_create_accepts_string_media_type(client): + """Ensure that string MIME types are correctly parsed into MediaType enums.""" + respx.post(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json=create_response()) + ) + + activity = client.activities.create(media_type="image/png", media_size=10) + + assert activity.media_type is MediaType.PNG + + +@respx.mock +def test_create_includes_owner_ids(client): + """Verify that provided owner IDs are included while empty ones are omitted.""" + route = respx.post(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json=create_response()) + ) + + client.activities.create(media_type=MediaType.PNG, media_size=1, user_id=SPACE_ID) + + body = route.calls.last.request.read().decode() + assert str(SPACE_ID) in body + assert "account_id" not in body # omitted rather than sent as null + assert "guest_id" not in body # the client never creates guest-owned activities + + +@respx.mock +def test_create_overrides_default_space(client): + """Ensure an explicitly provided space ID overrides the client default space.""" + other = "99999999-9999-9999-9999-999999999999" + route = respx.post(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json=create_response()) + ) + + client.activities.create(media_type=MediaType.PNG, media_size=1, space_id=other) + + assert other in route.calls.last.request.read().decode() + + +def test_create_without_space_id_raises(): + """Check that omitting a space ID completely raises a local validation error.""" + from guard_client import GuardClient + + bare = GuardClient(api_key="k", base_url=BASE_URL) + try: + with pytest.raises(GuardError, match="space_id is required"): + bare.activities.create(media_type=MediaType.PNG, media_size=1) + finally: + bare.close() + + +@respx.mock +def test_upload_posts_fields_and_file(client): + """Verify that the presigned upload fields precede the file bytes in the body.""" + route = respx.post(UPLOAD_URL).mock(return_value=httpx.Response(204)) + activity = _make_activity() + + client.activities.upload(activity.upload_data, png_bytes()) + + body = route.calls.last.request.read() + assert b'name="key"' in body + assert b'name="file"' in body + # presigned policy fields must precede the file part + assert body.index(b'name="key"') < body.index(b'name="file"') + + +@respx.mock +def test_confirm(client): + """ + Ensure that confirming an upload transitions the activity status to processing. + """ + route = respx.post(f"{ACTIVITIES_URL}{ACTIVITY_ID}/confirm").mock( + return_value=httpx.Response(200, json=confirm_response()) + ) + + activity = client.activities.confirm(ACTIVITY_ID) + + assert route.called + assert activity.status is ActivityStatus.PROCESSING + + +@respx.mock +def test_confirm_sends_no_guest_header(client): + """ + The guest flow is gone so every request goes out as the authenticated identity. + """ + route = respx.post(f"{ACTIVITIES_URL}{ACTIVITY_ID}/confirm").mock( + return_value=httpx.Response(200, json=confirm_response()) + ) + + client.activities.confirm(ACTIVITY_ID) + + assert "x-guest-id" not in route.calls.last.request.headers + + +def test_confirm_rejects_guest_id(client): + """Verify that passing the removed guest_id argument raises a TypeError.""" + with pytest.raises(TypeError): + client.activities.confirm(ACTIVITY_ID, guest_id=SPACE_ID) + + +@respx.mock +def test_get_status(client): + """Ensure that polling an activity status returns the correct current state.""" + respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + return_value=httpx.Response(200, json=status_response("processing")) + ) + + status = client.activities.get_status(ACTIVITY_ID) + + assert status.status is ActivityStatus.PROCESSING + + +@respx.mock +def test_get_returns_results(client): + """Verify that fetching an activity retrieves its full detailed results payload.""" + respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}").mock( + return_value=httpx.Response(200, json=detail_response()) + ) + + detail = client.activities.get(ACTIVITY_ID) + + assert detail.result_payload is not None + assert detail.result_payload.results[0].label == "Deepfake" + assert detail.result_payload.results[0].score == 87 + + +@respx.mock +def test_get_tolerates_missing_result(client): + """A still-processing activity has no result_payload yet.""" + respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}").mock( + return_value=httpx.Response( + 200, json=detail_response(status="processing", result_payload=None) + ) + ) + + detail = client.activities.get(ACTIVITY_ID) + + assert detail.result_payload is None + + +@respx.mock +def test_list_unwraps_data_envelope(client): + """Ensure the list endpoint correctly unwraps the paginated data envelope.""" + respx.get(ACTIVITIES_URL).mock( + return_value=httpx.Response( + 200, json={"data": [confirm_response()], "count": 1} + ) + ) + + activities = client.activities.list(space_id=SPACE_ID) + + assert len(activities) == 1 + assert activities[0].id == ACTIVITY_ID + + +@respx.mock +def test_list_passes_statuses(client): + """Verify that multiple status filters are correctly sent in the query.""" + route = respx.get(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json={"data": []}) + ) + + client.activities.list(statuses=[ActivityStatus.COMPLETED, "failed"], limit=5) + + params = route.calls.last.request.url.params + assert params.get_list("statuses") == ["completed", "failed"] + assert params["limit"] == "5" + + +@respx.mock +def test_list_returns_page_with_count(client): + """Ensure the returned page object correctly exposes the total match count.""" + respx.get(ACTIVITIES_URL).mock( + return_value=httpx.Response( + 200, json={"data": [confirm_response()], "count": 17} + ) + ) + + page = client.activities.list() + + assert page.count == 17 + assert len(page) == 1 + assert page.has_more is True + + +@respx.mock +def test_list_passes_sorting(client): + """Verify that sort_by and sort_order parameters are properly sent to the API.""" + route = respx.get(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json={"data": [], "count": 0}) + ) + + client.activities.list(sort_by="created_at", sort_order="asc") + + params = route.calls.last.request.url.params + assert params["sort_by"] == "created_at" + assert params["sort_order"] == "asc" + + +@respx.mock +def test_list_passes_date_range(client): + """Ensure that date ranges are correctly formatted as ISO-8601 strings.""" + route = respx.get(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json={"data": [], "count": 0}) + ) + + client.activities.list( + start_date=datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc), + end_date="2026-02-01T00:00:00Z", + ) + + params = route.calls.last.request.url.params + assert params["start_date"] == "2026-01-02T03:04:05+00:00" # datetime -> ISO + assert params["end_date"] == "2026-02-01T00:00:00Z" # string passes through + + +@respx.mock +def test_list_accepts_both_owner_filters(client): + """ + Unlike /spaces/, this route applies both owner filters independently. + + The read_activities endpoint has no mutual-exclusion check, so imposing one + client-side would block a query the API actually serves. + """ + route = respx.get(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json={"data": [], "count": 0}) + ) + + client.activities.list(user_id=SPACE_ID, organization_id=ACTIVITY_ID) + + params = route.calls.last.request.url.params + assert params["user_id"] == str(SPACE_ID) + assert params["organization_id"] == str(ACTIVITY_ID) + + +@pytest.mark.parametrize( + "too_old", + [ + datetime(2000, 1, 1, tzinfo=timezone.utc), + datetime(2000, 1, 1), # naive, treated as UTC like the server does + date(2000, 1, 1), + "2000-01-01T00:00:00+00:00", + "2000-01-01T00:00:00Z", # fromisoformat rejects "Z" before 3.11 + "2000-01-01", + ], +) +@respx.mock +def test_list_rejects_start_date_beyond_retention(client, too_old): + """The API keeps one year of history and returns 400 for anything older.""" + route = respx.get(ACTIVITIES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="only 365 days of history"): + client.activities.list(start_date=too_old) + + assert not route.called + + +@pytest.mark.parametrize( + "recent", + [ + None, + datetime.now(timezone.utc) - timedelta(days=364), + datetime.now(timezone.utc) - timedelta(days=365) + timedelta(hours=1), + datetime.now(timezone.utc), + "not-a-date", # unparseable: leave format validation to the server + ], +) +@respx.mock +def test_list_allows_dates_within_retention(client, recent): + """Verify that dates falling within the API retention window are accepted.""" + route = respx.get(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json={"data": [], "count": 0}) + ) + + client.activities.list(start_date=recent) + + assert route.called + + +@respx.mock +def test_retention_guard_has_clock_grace(client): + """A boundary date must not be rejected by a client clock running slightly fast.""" + route = respx.get(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json={"data": [], "count": 0}) + ) + + # exactly one year back: the server's cutoff is later than ours, so it would accept + client.activities.list(start_date=datetime.now(timezone.utc) - MAX_HISTORY) + + assert route.called + + +@respx.mock +def test_end_date_is_not_retention_checked(client): + """Only start_date has a retention rule since end_date is unconstrained.""" + route = respx.get(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json={"data": [], "count": 0}) + ) + + client.activities.list(end_date=datetime(2000, 1, 1, tzinfo=timezone.utc)) + + assert route.called + + +@respx.mock +def test_list_rejects_invalid_sort(client): + """Ensure that an invalid sort order raises an error before hitting the network.""" + route = respx.get(ACTIVITIES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="Invalid sort_order"): + client.activities.list(sort_order="sideways") + + assert not route.called + + +@respx.mock +def test_iter_all_walks_pages(client): + """Verify that iter_all correctly paginates through all available results.""" + route = respx.get(ACTIVITIES_URL).mock( + side_effect=[ + httpx.Response( + 200, json={"data": [confirm_response(), confirm_response()], "count": 3} + ), + httpx.Response(200, json={"data": [confirm_response()], "count": 3}), + ] + ) + + assert len(list(client.activities.iter_all(page_size=2))) == 3 + assert route.call_count == 2 + + +@respx.mock +def test_wait_until_done_polls_until_complete(client): + """Ensure wait_until_done polls correctly until a completed status is returned.""" + route = respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + side_effect=[ + httpx.Response(200, json=status_response("pending_upload")), + httpx.Response(200, json=status_response("processing")), + httpx.Response(200, json=status_response("completed")), + ] + ) + + result = client.activities.wait_until_done(ACTIVITY_ID, interval=0.01) + + assert result.status is ActivityStatus.COMPLETED + assert route.call_count == 3 + + +@pytest.mark.parametrize("terminal", ["failed", "canceled"]) +@respx.mock +def test_wait_until_done_raises_on_failure(client, terminal): + """Verify that wait_until_done raises an error if the activity fails or cancels.""" + respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + return_value=httpx.Response(200, json=status_response(terminal)) + ) + + with pytest.raises(ActivityFailedError) as exc_info: + client.activities.wait_until_done(ACTIVITY_ID, interval=0.01) + + assert exc_info.value.status == terminal + assert exc_info.value.activity_id == ACTIVITY_ID + + +@respx.mock +def test_wait_until_done_times_out(client): + """Ensure wait_until_done raises a timeout error if the polling deadline passes.""" + respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + return_value=httpx.Response(200, json=status_response("processing")) + ) + + with pytest.raises(GuardTimeoutError) as exc_info: + client.activities.wait_until_done(ACTIVITY_ID, interval=10.0, timeout=0.0) + + assert exc_info.value.activity_id == ACTIVITY_ID + assert "processing" in str(exc_info.value) + + +@respx.mock +async def test_async_create_and_status(async_client): + """Verify that the async client can successfully create and poll an activity.""" + respx.post(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json=create_response()) + ) + respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + return_value=httpx.Response(200, json=status_response("completed")) + ) + + activity = await async_client.activities.create( + media_type=MediaType.PNG, media_size=8 + ) + status = await async_client.activities.get_status(activity.id) + + assert activity.id == ACTIVITY_ID + assert status.status is ActivityStatus.COMPLETED + + +@respx.mock +async def test_async_wait_until_done_uses_asyncio_sleep(async_client, monkeypatch): + """The async poller must never block the event loop with time.sleep.""" + calls = [] + + async def _tracking_sleep(delay): + calls.append(delay) + + monkeypatch.setattr("guard_client.activities.asyncio.sleep", _tracking_sleep) + + def _boom(_): + raise AssertionError("async path must not call time.sleep") + + monkeypatch.setattr("guard_client.activities.time.sleep", _boom) + + respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + side_effect=[ + httpx.Response(200, json=status_response("processing")), + httpx.Response(200, json=status_response("completed")), + ] + ) + + await async_client.activities.wait_until_done(ACTIVITY_ID, interval=0.25) + + assert calls == [0.25] + + +@respx.mock +async def test_async_wait_until_done_raises_on_failure(async_client): + """Ensure the async wait_until_done raises an error if the activity fails.""" + respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + return_value=httpx.Response(200, json=status_response("failed")) + ) + + with pytest.raises(ActivityFailedError): + await async_client.activities.wait_until_done(ACTIVITY_ID, interval=0.01) + + +@respx.mock +async def test_async_upload(async_client): + """Verify that the async client successfully uploads media to the presigned URL.""" + route = respx.post(UPLOAD_URL).mock(return_value=httpx.Response(204)) + activity = _make_activity() + + await async_client.activities.upload(activity.upload_data, png_bytes()) + + assert route.called + + +def _make_activity(): + """Helper to build a valid ActivityCreateResponse for testing.""" + from guard_client import ActivityCreateResponse + + return ActivityCreateResponse.model_validate(create_response()) diff --git a/tests/test_analyze.py b/tests/test_analyze.py new file mode 100644 index 0000000..71ca09d --- /dev/null +++ b/tests/test_analyze.py @@ -0,0 +1,276 @@ +""" +End-to-end tests for the high-level analyze lifecycle. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from guard_client import ( + ActivityFailedError, + AsyncGuardClient, + Engine, + GuardClient, + GuardError, + UnsupportedMediaTypeError, +) + +from .conftest import ( + ACTIVITY_ID, + API_KEY, + BASE_URL, + SPACE_ID, + TASK_ID, + UPLOAD_URL, + confirm_response, + create_response, + detail_response, + png_bytes, + status_response, +) + +ACTIVITIES_URL = f"{BASE_URL}/api/v1/activities/" + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch): + """Ensure polling tests do not actually wait during execution.""" + monkeypatch.setattr("guard_client.activities.time.sleep", lambda _: None) + + async def _async_sleep(_): + return None + + monkeypatch.setattr("guard_client.activities.asyncio.sleep", _async_sleep) + + +def mock_full_lifecycle(status: str = "completed"): + """Wire up every route the happy path touches and return the route objects.""" + return { + "create": respx.post(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json=create_response()) + ), + "upload": respx.post(UPLOAD_URL).mock(return_value=httpx.Response(204)), + "confirm": respx.post(f"{ACTIVITIES_URL}{ACTIVITY_ID}/confirm").mock( + return_value=httpx.Response(200, json=confirm_response()) + ), + "status": respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + return_value=httpx.Response(200, json=status_response(status)) + ), + "detail": respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}").mock( + return_value=httpx.Response(200, json=detail_response(status)) + ), + } + + +@respx.mock +def test_analyze_runs_full_lifecycle(client, png): + """Verify that analyze executes the complete sequence of API calls.""" + routes = mock_full_lifecycle() + + result = client.analyze(png) + + for name, route in routes.items(): + assert route.called, f"{name} was never called" + + assert result.engine is Engine.CLOUD + assert result.activity_id == ACTIVITY_ID + assert result.max_score == 87 + assert result.results[0].label == "Deepfake" + assert result.score_for(TASK_ID) == 87 + + +@respx.mock +def test_cloud_results_leave_the_local_only_fields_unset(client, png): + """Ensure cloud results leave local-only fields like detected and matches unset.""" + mock_full_lifecycle() + + result = client.analyze(png) + + assert result.results[0].detected is None + assert result.results[0].matches is None + + +@respx.mock +def test_analyze_accepts_raw_bytes(client): + """Verify that analyze correctly accepts raw bytes directly.""" + mock_full_lifecycle() + + result = client.analyze(png_bytes()) + + assert result.activity_id == ACTIVITY_ID + + +@respx.mock +def test_analyze_accepts_file_object(client, png): + """Verify that analyze correctly accepts an open binary file object.""" + mock_full_lifecycle() + + with open(png, "rb") as handle: + result = client.analyze(handle) + + assert result.activity_id == ACTIVITY_ID + + +@respx.mock +def test_analyze_sends_detected_type_and_size(client, png): + """Ensure the detected media type and size are included in the create request.""" + routes = mock_full_lifecycle() + + client.analyze(png) + + body = routes["create"].calls.last.request.read().decode().replace(" ", "") + assert '"media_type":"image/png"' in body + assert f'"media_size":{len(png_bytes())}' in body + + +@respx.mock +def test_analyze_upload_is_unauthenticated(client, png): + """Verify the presigned S3 POST request does not carry the API bearer token.""" + routes = mock_full_lifecycle() + + client.analyze(png) + + upload_request = routes["upload"].calls.last.request + assert "Authorization" not in upload_request.headers + + create_request = routes["create"].calls.last.request + assert create_request.headers["Authorization"] == f"Bearer {API_KEY}" + + +@respx.mock +def test_analyze_orders_multipart_fields_before_file(client, png): + """Ensure presigned policy fields precede the file part in the multipart body.""" + routes = mock_full_lifecycle() + + client.analyze(png) + + body = routes["upload"].calls.last.request.read() + assert body.index(b'name="key"') < body.index(b'name="file"') + + +@respx.mock +def test_analyze_raises_on_failed_activity(client, png): + """Verify an error is raised if the activity ends in a failed state.""" + mock_full_lifecycle(status="failed") + + with pytest.raises(ActivityFailedError) as exc_info: + client.analyze(png) + + assert exc_info.value.status == "failed" + + +@respx.mock +def test_analyze_handles_empty_results(client, png): + """Ensure analyze handles missing result payloads gracefully.""" + respx.post(ACTIVITIES_URL).mock( + return_value=httpx.Response(200, json=create_response()) + ) + respx.post(UPLOAD_URL).mock(return_value=httpx.Response(204)) + respx.post(f"{ACTIVITIES_URL}{ACTIVITY_ID}/confirm").mock( + return_value=httpx.Response(200, json=confirm_response()) + ) + respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}/status").mock( + return_value=httpx.Response(200, json=status_response("completed")) + ) + respx.get(f"{ACTIVITIES_URL}{ACTIVITY_ID}").mock( + return_value=httpx.Response(200, json=detail_response(result_payload=None)) + ) + + result = client.analyze(png_bytes()) + + assert result.results == [] + assert result.max_score == 0 + assert result.score_for(TASK_ID) is None + + +@respx.mock +def test_analyze_rejects_unsupported_media_before_any_call(client, tmp_path): + """Verify unsupported media types are rejected locally before network calls.""" + route = respx.post(ACTIVITIES_URL).mock(return_value=httpx.Response(200)) + bad = tmp_path / "doc.pdf" + bad.write_bytes(b"%PDF-1.4 nope") + + with pytest.raises(UnsupportedMediaTypeError): + client.analyze(bad) + + assert not route.called + + +@respx.mock +def test_analyze_space_id_override(client, png): + """Ensure an explicitly passed space ID overrides the client default.""" + other = "99999999-9999-9999-9999-999999999999" + routes = mock_full_lifecycle() + + client.analyze(png, space_id=other) + + assert other in routes["create"].calls.last.request.read().decode() + + +def test_analyze_without_space_id_raises(png): + """Check that omitting a space ID completely raises a validation error.""" + bare = GuardClient(api_key=API_KEY, base_url=BASE_URL) + try: + with pytest.raises(GuardError, match="space_id is required"): + bare.analyze(png) + finally: + bare.close() + + +def test_cloud_client_requires_api_key(monkeypatch): + """Verify the cloud engine requires an API key.""" + monkeypatch.delenv("GUARD_API_KEY", raising=False) + + with pytest.raises(GuardError, match="API key is required"): + GuardClient(space_id=SPACE_ID) + + +def test_local_client_needs_no_api_key(monkeypatch): + """Verify the local engine initializes successfully without an API key.""" + monkeypatch.delenv("GUARD_API_KEY", raising=False) + + client = GuardClient(engine="local") + + assert client.engine is Engine.LOCAL + + +def test_unknown_engine_rejected(): + """Check that passing an invalid engine name raises an error.""" + with pytest.raises(GuardError, match=r"Invalid engine='quantum'.*'cloud', 'local'"): + GuardClient(api_key=API_KEY, engine="quantum") + + +@respx.mock +async def test_async_analyze_runs_full_lifecycle(async_client, png): + """Verify the async client executes the full analyze lifecycle.""" + routes = mock_full_lifecycle() + + result = await async_client.analyze(png) + + for name, route in routes.items(): + assert route.called, f"{name} was never called" + + assert result.engine is Engine.CLOUD + assert result.activity_id == ACTIVITY_ID + assert result.max_score == 87 + + +@respx.mock +async def test_async_analyze_raises_on_canceled(async_client, png): + """Ensure the async client raises an error if an activity is canceled.""" + mock_full_lifecycle(status="canceled") + + with pytest.raises(ActivityFailedError) as exc_info: + await async_client.analyze(png) + + assert exc_info.value.status == "canceled" + + +async def test_async_cloud_client_requires_api_key(monkeypatch): + """Verify the async cloud client requires an API key.""" + monkeypatch.delenv("GUARD_API_KEY", raising=False) + + with pytest.raises(GuardError, match="API key is required"): + AsyncGuardClient(space_id=SPACE_ID) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..3165069 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,158 @@ +""" +Every cloud request must carry an API key. + +The constructor already refuses a keyless cloud client, but a client built with +`engine="local"` is legitimately keyless and still exposes every cloud resource. +These tests pin the transport-level backstop that closes that path. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from guard_client import AsyncGuardClient, GuardClient, GuardError +from guard_client.transport import AsyncTransport, SyncTransport, TransportConfig + +from .conftest import BASE_URL, SPACE_ID, UPLOAD_URL, png_bytes + +SPACES_URL = f"{BASE_URL}/spaces/" +ACTIVITIES_URL = f"{BASE_URL}/activities/" + + +@pytest.fixture +def keyless(isolate_env): + """ + Provide a local-engine client that is legitimately constructible without a key. + """ + with GuardClient( + engine="local", space_id=SPACE_ID, base_url=BASE_URL, max_retries=0 + ) as c: + yield c + + +@pytest.fixture +async def async_keyless(isolate_env): + """ + Provide an async local-engine client that is legitimately constructible without a + key. + """ + async with AsyncGuardClient( + engine="local", space_id=SPACE_ID, base_url=BASE_URL, max_retries=0 + ) as c: + yield c + + +@respx.mock +def test_resource_call_without_key_raises(keyless): + """Ensure that making a cloud resource call without an API key raises an error.""" + route = respx.get(SPACES_URL).mock(return_value=httpx.Response(200, json={})) + + with pytest.raises(GuardError, match="API key is required"): + keyless.spaces.list() + + assert not route.called, "the refusal must happen before any request is sent" + + +@respx.mock +def test_per_call_cloud_override_without_key_raises(keyless): + """Verify the per-call cloud engine override does not escape the API key guard.""" + route = respx.post(ACTIVITIES_URL).mock(return_value=httpx.Response(200, json={})) + + with pytest.raises(GuardError, match="API key is required"): + keyless.analyze(png_bytes(), engine="cloud") + + assert not route.called + + +@respx.mock +async def test_async_resource_call_without_key_raises(async_keyless): + """ + Ensure that making an async cloud resource call without an API key raises an error. + """ + route = respx.get(SPACES_URL).mock(return_value=httpx.Response(200, json={})) + + with pytest.raises(GuardError, match="API key is required"): + await async_keyless.spaces.list() + + assert not route.called + + +@respx.mock +async def test_async_per_call_cloud_override_without_key_raises(async_keyless): + """ + Verify the async per-call cloud engine override does not escape the API key guard. + """ + route = respx.post(ACTIVITIES_URL).mock(return_value=httpx.Response(200, json={})) + + with pytest.raises(GuardError, match="API key is required"): + await async_keyless.analyze(png_bytes(), engine="cloud") + + assert not route.called + + +def test_error_names_every_way_to_supply_a_key(keyless): + """ + Check that the error message lists all valid methods to supply an API key. + """ + with pytest.raises(GuardError) as excinfo: + keyless.tasks.list() + + message = str(excinfo.value) + assert "api_key=" in message + assert "GUARD_API_KEY" in message + assert ".env" in message + + +@respx.mock +def test_presigned_upload_still_works_without_a_key(): + """ + Ensure presigned uploads succeed without an API key since they bypass the auth + guard. + """ + route = respx.post(UPLOAD_URL).mock(return_value=httpx.Response(204)) + config = TransportConfig(base_url=BASE_URL, max_retries=0) + + with SyncTransport(config) as transport: + transport.upload(UPLOAD_URL, {"key": "uploads/a"}, "a.png", png_bytes()) + + assert route.called + assert "Authorization" not in route.calls.last.request.headers + + +@respx.mock +async def test_async_presigned_upload_still_works_without_a_key(): + """ + Ensure async presigned uploads succeed without an API key since they bypass auth. + """ + route = respx.post(UPLOAD_URL).mock(return_value=httpx.Response(204)) + config = TransportConfig(base_url=BASE_URL, max_retries=0) + + async with AsyncTransport(config) as transport: + await transport.upload(UPLOAD_URL, {"key": "uploads/a"}, "a.png", png_bytes()) + + assert route.called + assert "Authorization" not in route.calls.last.request.headers + + +def test_local_detection_still_works_without_a_key(keyless): + """ + Verify that local on-device detection functions correctly without an API key. + """ + from guard_client import Engine + from guard_client.local import LocalRunner + + from .test_local import FakeEngine + + keyless._local = LocalRunner(engine=FakeEngine()) + + result = keyless.analyze(png_bytes()) + + assert result.engine is Engine.LOCAL + + +def test_analyze_rejects_guest_id(client): + """Ensure that passing a guest ID to analyze raises a type error.""" + with pytest.raises(TypeError): + client.analyze(png_bytes(), guest_id=SPACE_ID) diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..f43bdf1 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,128 @@ +""" +The contract that the guard-python package depends on. +""" + +import asyncio +import inspect +import struct +import zlib +from typing import Any, Dict, List, Union + +import pytest + +import guard_local + +SUPPORTED = [ + "image/jpeg", "image/png", "image/webp", "image/gif", + "image/heic", "video/mp4", "video/webm", "video/quicktime", +] + + +def _png() -> bytes: + """Generate a real, decodable 1x1 PNG so the test requires no external fixture files.""" + + def chunk(tag: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) + idat = zlib.compress(b"\x00\x00\x00\x00") + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"") + + +PNG = _png() + + +def test_factory_is_exported_from_the_package_root() -> None: + """Ensure the local engine can be imported directly from the package root.""" + assert hasattr(guard_local, "LocalDetectorEngine") + + +def test_constructs_with_no_arguments() -> None: + """Ensure a bare constructor call works since the model path environment variable is usually unset.""" + guard_local.LocalDetectorEngine() + + +def test_model_path_is_accepted_positionally(tmp_path: Any) -> None: + """Verify that the constructor accepts the model path as a positional argument.""" + sig = inspect.signature(guard_local.LocalDetectorEngine.__init__) + first = list(sig.parameters.values())[1] + assert first.default is not inspect.Parameter.empty + assert first.kind in (first.POSITIONAL_ONLY, first.POSITIONAL_OR_KEYWORD) + + +def test_analyze_takes_bytes_and_a_media_type() -> None: + """ + Verify that analysis takes raw bytes and a media type. + + It must not require a path or filename because the client has already read the + source media into memory by the time this is called. + """ + raw = guard_local.LocalDetectorEngine().analyze(PNG, "image/png") + + entries = raw if isinstance(raw, list) else [raw] + assert entries and all(isinstance(e, dict) for e in entries) + + +def test_every_entry_has_a_label_and_a_unit_interval_score() -> None: + """Verify that scores are returned as floats between 0.0 and 1.0. + + This is critical because the Guard client rescales by value. Returning an integer 1 + would be incorrectly rescaled to 100. + """ + raw = guard_local.LocalDetectorEngine().analyze(PNG, "image/png") + + for entry in raw if isinstance(raw, list) else [raw]: + assert isinstance(entry.get("label"), str) and entry["label"] + assert 0.0 <= float(entry["score"]) <= 1.0 + + +def test_labels_are_stable_across_calls() -> None: + """ + Ensure labels remain entirely stable across multiple calls. + + Local task IDs are generated using a UUID5 hash of the label. Because + of this, silently changing a label would inadvertently change the ID. + """ + def labels(raw: Union[Dict[str, Any], List[Dict[str, Any]]]) -> List[str]: + return sorted(e["label"] for e in (raw if isinstance(raw, list) else [raw])) + + engine = guard_local.LocalDetectorEngine() + + assert labels(engine.analyze(PNG, "image/png")) == labels(engine.analyze(PNG, "image/png")) + + +@pytest.mark.parametrize("media_type", SUPPORTED) +def test_every_supported_media_type_is_handled_or_cleanly_rejected(media_type: str) -> None: + """ + Verify that the engine handles or cleanly rejects every supported media type. + + The client forwards all eight media types to the engine, and the engine must not + fail opaquely on any of them. + """ + engine = guard_local.LocalDetectorEngine() + try: + engine.analyze(PNG, media_type) + except guard_local.GuardLocalError: + pass # an explicit, documented rejection is fine + + +def test_async_entry_point_if_present_is_a_coroutine() -> None: + """ + Verify that the async entry point is a coroutine if it is present. + + This method is optional. If it is absent, the client safely falls back to offloading + the synchronous work to a background thread. + """ + engine = guard_local.LocalDetectorEngine() + analyze_async = getattr(engine, "analyze_async", None) + if analyze_async is None: + pytest.skip("sync-only engine; client offloads to a thread") + + raw = asyncio.run(analyze_async(PNG, "image/png")) + + assert isinstance(raw, (dict, list)) diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..f603afa --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,414 @@ +"""Tests for show() and save(). + +The viewer launcher is always monkeypatched. No test may actually open an application. +""" + +from __future__ import annotations + +from pathlib import Path + +import httpx +import pytest +import respx + +from guard_client import ( + ActivityResultItem, + DetectionResult, + Engine, + GuardError, + MediaType, + Share, + UnsupportedMediaTypeError, + load_media, + save, + show, +) +from guard_client import display as display_module + +from .conftest import TASK_ID, png_bytes, share_response + +MEDIA_URL = "https://s3.test.invalid/app/results/solution.png" + + +@pytest.fixture +def viewer(monkeypatch): + """Capture viewer launches instead of actually performing them.""" + opened = [] + + def fake_open(data, media_type, filename): + path = Path(filename) + opened.append((data, media_type, path)) + return path + + monkeypatch.setattr(display_module, "_open_in_viewer", fake_open) + return opened + + +@pytest.fixture +def displayed(monkeypatch): + """Capture inline renders while simulating a Jupyter kernel environment.""" + rendered = [] + monkeypatch.setattr(display_module, "_in_notebook", lambda: True) + monkeypatch.setattr( + display_module, + "_display_inline", + lambda data, media_type, width: rendered.append((data, media_type, width)), + ) + return rendered + + +def result_item(media_url=MEDIA_URL) -> ActivityResultItem: + """Create a mock ActivityResultItem for testing purposes.""" + return ActivityResultItem( + task_id=TASK_ID, score=87, label="Deepfake", media_url=media_url + ) + + +def test_load_media_from_path(png): + """Verify load_media correctly loads data from a file path.""" + data, media_type, name = load_media(png) + + assert data == png_bytes() + assert media_type is MediaType.PNG + assert name == "sample.png" + + +def test_load_media_from_bytes(): + """Verify load_media correctly processes raw bytes.""" + data, media_type, _ = load_media(png_bytes(), filename="x.png") + assert (data, media_type) == (png_bytes(), MediaType.PNG) + + +def test_load_media_from_file_object(png): + """Verify load_media correctly reads from an open binary file object.""" + with open(png, "rb") as handle: + data, media_type, name = load_media(handle) + + assert data == png_bytes() + assert name == "sample.png" + + +@respx.mock +def test_load_media_from_url(): + """Verify load_media successfully downloads media from a URL.""" + respx.get(MEDIA_URL).mock(return_value=httpx.Response(200, content=png_bytes())) + + data, media_type, name = load_media(MEDIA_URL) + + assert data == png_bytes() + assert media_type is MediaType.PNG + assert name == "solution.png" + + +@respx.mock +def test_load_media_from_result_item(): + """Verify load_media successfully extracts media from an ActivityResultItem.""" + respx.get(MEDIA_URL).mock(return_value=httpx.Response(200, content=png_bytes())) + + data, _, _ = load_media(result_item()) + + assert data == png_bytes() + + +@respx.mock +def test_load_media_from_share(): + """Verify load_media successfully extracts media from a Share object.""" + share = Share.model_validate(share_response(media_url=MEDIA_URL)) + respx.get(MEDIA_URL).mock(return_value=httpx.Response(200, content=png_bytes())) + + data, _, _ = load_media(share) + + assert data == png_bytes() + + +@respx.mock +def test_fetch_sends_no_authorization_header(): + """Ensure load_media strips the API key when fetching from external URLs.""" + route = respx.get(MEDIA_URL).mock( + return_value=httpx.Response(200, content=png_bytes()) + ) + + load_media(MEDIA_URL) + + headers = route.calls.last.request.headers + assert "Authorization" not in headers + assert "x-guest-id" not in headers + + +def test_result_without_media_raises(): + """Verify load_media raises an error when passed a result missing media.""" + with pytest.raises(GuardError, match="no media_url"): + load_media(result_item(media_url=None)) + + +@respx.mock +def test_failed_download_raises(): + """Verify load_media raises an error if the media download fails.""" + respx.get(MEDIA_URL).mock(return_value=httpx.Response(404)) + + with pytest.raises(GuardError, match="Could not download"): + load_media(MEDIA_URL) + + +@respx.mock +def test_url_with_query_string_keeps_a_usable_name(): + """ + Ensure load_media properly parses a filename from a URL containing a query string. + """ + url = f"{MEDIA_URL}?signature=abc" + respx.get(url).mock(return_value=httpx.Response(200, content=png_bytes())) + + _, _, name = load_media(url) + + assert name == "solution.png" + + +def test_unsupported_type_is_rejected(): + """Verify load_media raises an error when given an unsupported media type.""" + with pytest.raises(UnsupportedMediaTypeError): + load_media(b"%PDF-1.4 nope", filename="doc.pdf") + + +def test_show_renders_inline_in_a_notebook(png, displayed, viewer): + """ + Ensure show() renders inline and avoids launching an external viewer in notebooks. + """ + show(png, width=400) + + assert len(displayed) == 1 + data, media_type, width = displayed[0] + assert (data, media_type, width) == (png_bytes(), MediaType.PNG, 400) + assert not viewer # never launches an application in a notebook + + +def test_show_uses_the_viewer_without_ipython(png, monkeypatch, viewer): + """Ensure show() launches the external viewer outside of a Jupyter notebook.""" + monkeypatch.setattr(display_module, "_in_notebook", lambda: False) + + show(png) + + assert len(viewer) == 1 + assert viewer[0][0] == png_bytes() + + +@pytest.mark.parametrize( + ("filename", "media_type"), + [("x.heic", MediaType.HEIC), ("x.mov", MediaType.QUICKTIME)], +) +def test_heic_and_quicktime_use_the_viewer_even_in_a_notebook( + displayed, viewer, capsys, filename, media_type +): + """ + Verify show() uses the external viewer for media types that browsers cannot render. + """ + show(b"\x00" * 32, media_type=media_type, filename=filename) + + assert not displayed + assert len(viewer) == 1 + assert "cannot be rendered inline" in capsys.readouterr().out + + +def test_browser_renderable_set_excludes_heic_and_quicktime(): + """Ensure the BROWSER_RENDERABLE set accurately reflects browser capabilities.""" + from guard_client import BROWSER_RENDERABLE + + assert MediaType.HEIC not in BROWSER_RENDERABLE + assert MediaType.QUICKTIME not in BROWSER_RENDERABLE + assert MediaType.PNG in BROWSER_RENDERABLE + assert MediaType.MP4 in BROWSER_RENDERABLE + + +def test_open_viewer_false_launches_nothing(png, monkeypatch, viewer): + """Verify show() respects the open_viewer=False flag by launching nothing.""" + monkeypatch.setattr(display_module, "_in_notebook", lambda: False) + + show(png, open_viewer=False) + + assert not viewer + + +@respx.mock +def test_show_detection_result_renders_each_item(displayed, viewer): + """Ensure show() processes every item containing media within a DetectionResult.""" + respx.get(MEDIA_URL).mock(return_value=httpx.Response(200, content=png_bytes())) + result = DetectionResult( + engine=Engine.CLOUD, + results=[ + ActivityResultItem( + task_id=TASK_ID, score=1, label="A", media_url=MEDIA_URL + ), + ActivityResultItem( + task_id=TASK_ID, score=2, label="B", media_url=MEDIA_URL + ), + ], + ) + + show(result) + + assert len(displayed) == 2 + assert not viewer + + +@respx.mock +def test_show_detection_result_skips_items_without_media(displayed, capsys): + """Verify show() safely skips results missing a media_url and prints a warning.""" + result = DetectionResult( + engine=Engine.LOCAL, + results=[ActivityResultItem(task_id=TASK_ID, score=1, label="A")], + ) + + show(result) + + assert not displayed + assert "No result in this detection carries an image" in capsys.readouterr().out + + +def test_not_a_notebook_without_ipython(monkeypatch): + """Ensure notebook detection correctly returns False when IPython is absent.""" + import builtins + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "IPython": + raise ImportError("No module named 'IPython'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + assert display_module._in_notebook() is False + + +def test_terminal_ipython_is_not_a_notebook(monkeypatch): + """Verify that a terminal IPython session does not count as a notebook.""" + import IPython + + class TerminalInteractiveShell: + pass + + monkeypatch.setattr(IPython, "get_ipython", lambda: TerminalInteractiveShell()) + + assert display_module._in_notebook() is False + + +def test_zmq_shell_is_a_notebook(monkeypatch): + """Ensure that a ZMQInteractiveShell is correctly identified as a notebook.""" + import IPython + + class ZMQInteractiveShell: + pass + + monkeypatch.setattr(IPython, "get_ipython", lambda: ZMQInteractiveShell()) + + assert display_module._in_notebook() is True + + +def test_plain_python_is_not_a_notebook(): + """ + Verify that get_ipython returning None correctly flags that we are not in a + notebook. + """ + assert display_module._in_notebook() is False + + +def test_display_inline_builds_a_real_image(monkeypatch): + """Ensure _display_inline constructs a proper IPython Image object.""" + import IPython.display + + captured = [] + monkeypatch.setattr(IPython.display, "display", captured.append) + + display_module._display_inline(png_bytes(), MediaType.PNG, 300) + + (obj,) = captured + assert isinstance(obj, IPython.display.Image) + assert obj.data == png_bytes() + assert obj.width == 300 + + +def test_display_inline_builds_a_real_video(monkeypatch): + """Ensure _display_inline constructs a proper IPython Video object.""" + import IPython.display + + captured = [] + monkeypatch.setattr(IPython.display, "display", captured.append) + + display_module._display_inline(b"\x00" * 32, MediaType.MP4, None) + + (obj,) = captured + assert isinstance(obj, IPython.display.Video) + # Embedded rather than linked, so a shared notebook keeps working. + assert obj.embed is True + assert obj.mimetype == "video/mp4" + + +def test_save_writes_bytes(tmp_path, png, viewer): + """ + Verify save() writes media bytes to the specified path without opening a viewer. + """ + target = save(png, tmp_path / "out.png") + + assert target.read_bytes() == png_bytes() + assert not viewer # save never opens anything + + +def test_save_appends_the_extension(tmp_path, png): + """Ensure save() automatically appends the correct extension if none is provided.""" + target = save(png, tmp_path / "out") + + assert target.suffix == ".png" + assert target.exists() + + +def test_save_into_a_directory(tmp_path, png): + """Verify save() properly names the file when given a directory path.""" + target = save(png, tmp_path) + + assert target == tmp_path / "sample.png" + assert target.read_bytes() == png_bytes() + + +def test_save_creates_missing_parents(tmp_path, png): + """Ensure save() automatically creates any missing parent directories.""" + target = save(png, tmp_path / "nested" / "deep" / "out.png") + assert target.exists() + + +def test_save_refuses_to_overwrite_when_asked(tmp_path, png): + """Verify save() refuses to overwrite an existing file if overwrite=False.""" + target = tmp_path / "out.png" + target.write_bytes(b"existing") + + with pytest.raises(GuardError, match="already exists"): + save(png, target, overwrite=False) + + assert target.read_bytes() == b"existing" + + +def test_save_overwrites_by_default(tmp_path, png): + """Ensure save() silently overwrites an existing file by default.""" + target = tmp_path / "out.png" + target.write_bytes(b"existing") + + save(png, target) + + assert target.read_bytes() == png_bytes() + + +@respx.mock +def test_save_a_result_item(tmp_path, viewer): + """Verify save() properly downloads and writes an ActivityResultItem.""" + respx.get(MEDIA_URL).mock(return_value=httpx.Response(200, content=png_bytes())) + + target = save(result_item(), tmp_path / "solution.png") + + assert target.read_bytes() == png_bytes() + assert not viewer + + +def test_save_heic_never_opens_a_viewer(tmp_path, viewer): + """Ensure save() handles HEIC files properly without triggering a viewer launch.""" + target = save(b"\x00" * 32, tmp_path / "x.heic", media_type=MediaType.HEIC) + + assert target.exists() + assert not viewer diff --git a/tests/test_docstyle.py b/tests/test_docstyle.py new file mode 100644 index 0000000..20199a3 --- /dev/null +++ b/tests/test_docstyle.py @@ -0,0 +1,88 @@ +""" +House docstring style, for the one rule ruff cannot express. + +Ruff's D213 requires the summary on the second line of a *multi-line* docstring, and +D200 (disabled here) would otherwise collapse a three-line docstring back to one line. +Neither rule requires the expanded form, so ``\"\"\"One liner.\"\"\"`` passes silently. +This walks the package and insists on the expanded form everywhere. +""" + +from __future__ import annotations + +import ast +import pathlib + +import pytest + +SRC = pathlib.Path(__file__).resolve().parent.parent / "src" / "guard_client" +Definition = (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + + +def _definitions(path: pathlib.Path): + """Yield (qualified name, node) for every documentable definition in a file.""" + tree = ast.parse(path.read_text(), filename=str(path)) + yield path.name, tree + + stack = [(path.name, node) for node in tree.body] + while stack: + prefix, node = stack.pop() + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + name = f"{prefix}::{node.name}" + yield name, node + stack.extend((name, child) for child in node.body) + + +def _source_files() -> list[pathlib.Path]: + """Find all Python source files in the package directory excluding init.""" + return sorted(p for p in SRC.glob("*.py") if p.name != "__init__.py") + + +def test_source_files_were_found(): + """Guard the guard: an empty glob would make every check below vacuous.""" + files = _source_files() + + assert len(files) >= 15, ( + f"expected the full package, found {[p.name for p in files]}" + ) + + +@pytest.mark.parametrize("path", _source_files(), ids=lambda p: p.name) +def test_every_definition_is_documented(path): + """Dunders included, matching ruff's D105 — several carry real behaviour.""" + missing = [ + name for name, node in _definitions(path) if ast.get_docstring(node) is None + ] + + assert not missing, f"undocumented: {missing}" + + +@pytest.mark.parametrize("path", _source_files(), ids=lambda p: p.name) +def test_summaries_start_on_the_line_below_the_quotes(path): + """The house style, matching the backend's 1205 docstrings and zero one-liners.""" + offenders = [] + for name, node in _definitions(path): + raw = ast.get_docstring(node, clean=False) + # an expanded docstring starts with the newline after the opening quotes; + # """Summary.""" and """Summary\n...""" both start with the text itself. + if raw is not None and not raw.startswith("\n"): + offenders.append(name) + + assert not offenders, ( + f"these use the single-line form; put the summary on the line below the " + f"opening quotes: {offenders}" + ) + + +@pytest.mark.parametrize("path", _source_files(), ids=lambda p: p.name) +def test_summaries_end_with_a_period(path): + """D415 covers this, but a named offender is easier to act on than a rule code.""" + offenders = [] + for name, node in _definitions(path): + doc = ast.get_docstring(node) + if doc is None: + continue + summary = doc.strip().splitlines()[0].strip() + if summary and not summary.endswith((".", "?", "!", ":")): + offenders.append(f"{name}: {summary!r}") + + assert not offenders, f"summaries must end with a period: {offenders}" diff --git a/tests/test_env.py b/tests/test_env.py new file mode 100644 index 0000000..155c731 --- /dev/null +++ b/tests/test_env.py @@ -0,0 +1,343 @@ +""" +Tests for settings resolution across arguments, os.environ, and a .env file. + +The autouse `isolate_env` fixture in conftest.py clears every real `GUARD_*` variable +and chdirs into an empty temporary directory. Therefore, tests here write their own +`.env` file into the current working directory and never touch the repository. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from guard_client import ( + DEFAULT_BASE_URL, + Engine, + GuardClient, + GuardError, + read_env_file, +) +from guard_client.env import EnvSource + +KEY = "key-from-somewhere" +SPACE = "11111111-1111-1111-1111-111111111111" + + +def write_env(**values: str) -> Path: + """Write a .env file into the temporary current working directory.""" + path = Path(".env") + path.write_text("\n".join(f"{k}={v}" for k, v in values.items()) + "\n") + return path + + +def test_dotenv_supplies_settings(): + """Verify that settings are correctly loaded from the .env file.""" + write_env( + GUARD_API_KEY=KEY, GUARD_SPACE_ID=SPACE, GUARD_BASE_URL="https://from-file" + ) + + client = GuardClient() + + assert client.base_url == "https://from-file" + assert client._config.api_key == KEY + assert str(client._space_id) == SPACE + + +def test_real_env_beats_dotenv(monkeypatch): + """ + Ensure that real environment variables override values in the .env file. + + A stale .env file must never shadow a secret injected by a CI pipeline. + """ + write_env(GUARD_API_KEY="from-file", GUARD_BASE_URL="https://from-file") + monkeypatch.setenv("GUARD_API_KEY", "from-env") + monkeypatch.setenv("GUARD_BASE_URL", "https://from-env") + + client = GuardClient(space_id=SPACE) + + assert client._config.api_key == "from-env" + assert client.base_url == "https://from-env" + + +def test_explicit_argument_beats_everything(monkeypatch): + """Ensure explicit constructor arguments override both the environment and .env.""" + write_env(GUARD_API_KEY="from-file", GUARD_BASE_URL="https://from-file") + monkeypatch.setenv("GUARD_API_KEY", "from-env") + monkeypatch.setenv("GUARD_BASE_URL", "https://from-env") + + client = GuardClient( + api_key="explicit", space_id=SPACE, base_url="https://explicit" + ) + + assert client._config.api_key == "explicit" + assert client.base_url == "https://explicit" + + +def test_defaults_apply_when_nothing_is_set(): + """Verify that the client applies correct defaults when no settings are provided.""" + write_env(GUARD_API_KEY=KEY, GUARD_SPACE_ID=SPACE) + + client = GuardClient() + + assert client.base_url == DEFAULT_BASE_URL + assert client.engine is Engine.CLOUD + assert client._config.locale == "en" + assert client._config.timeout == 30.0 + assert client._config.max_retries == 3 + + +def test_explicit_base_url_equal_to_default_is_not_overridden(monkeypatch): + """ + Check that passing the default base_url explicitly prevents overrides from the + environment. + """ + monkeypatch.setenv("GUARD_BASE_URL", "http://localhost:8000") + + client = GuardClient(api_key=KEY, space_id=SPACE, base_url=DEFAULT_BASE_URL) + + assert client.base_url == DEFAULT_BASE_URL + + +def test_reading_dotenv_does_not_mutate_os_environ(): + """ + Ensure that reading the .env file does not pollute os.environ. + + This is the headline promise: constructing a client cannot surprise other libraries. + """ + write_env(GUARD_API_KEY=KEY, GUARD_SPACE_ID=SPACE, GUARD_LOCALE="de") + before = dict(os.environ) + + client = GuardClient() + + assert client._config.locale == "de" # the file was genuinely read + assert os.environ == before + assert "GUARD_API_KEY" not in os.environ + + +def test_read_env_file_does_not_mutate_os_environ(): + """Verify that read_env_file keeps os.environ clean.""" + write_env(GUARD_API_KEY=KEY) + + values = read_env_file() + + assert values["GUARD_API_KEY"] == KEY + assert "GUARD_API_KEY" not in os.environ + + +def test_env_file_none_disables_file_reading(): + """ + Check that passing env_file=None completely disables reading from the .env file. + """ + write_env(GUARD_API_KEY=KEY, GUARD_SPACE_ID=SPACE) + + with pytest.raises(GuardError, match="API key is required"): + GuardClient(env_file=None) + + +def test_env_file_none_still_honours_real_env(monkeypatch): + """ + Verify that even with env_file=None, real environment variables are still honored. + """ + write_env(GUARD_BASE_URL="https://from-file") + monkeypatch.setenv("GUARD_API_KEY", KEY) + + client = GuardClient(space_id=SPACE, env_file=None) + + assert client._config.api_key == KEY + assert client.base_url == DEFAULT_BASE_URL # the file was ignored + + +def test_named_env_file_is_read(): + """Ensure the client reads from a specifically named .env file when requested.""" + Path("staging.env").write_text( + f"GUARD_API_KEY={KEY}\nGUARD_BASE_URL=https://staging\n" + ) + + client = GuardClient(space_id=SPACE, env_file="staging.env") + + assert client.base_url == "https://staging" + + +def test_missing_named_env_file_raises(): + """ + Verify that providing an explicit env file path that does not exist raises an error. + + An explicitly named file that is absent is a typo, not a valid state. + """ + with pytest.raises(GuardError, match="env file not found"): + GuardClient(api_key=KEY, space_id=SPACE, env_file="nope.env") + + +def test_missing_default_env_file_is_silent(monkeypatch): + """ + Ensure that the absence of a default .env file does not cause an error. + + Running without a .env file is normal in production. + """ + monkeypatch.setenv("GUARD_API_KEY", KEY) + + client = GuardClient(space_id=SPACE) + + assert client.base_url == DEFAULT_BASE_URL + + +def test_guard_env_file_selects_the_file(monkeypatch): + """ + Verify that the GUARD_ENV_FILE environment variable successfully redirects the .env + path. + """ + Path("other.env").write_text(f"GUARD_API_KEY={KEY}\nGUARD_BASE_URL=https://other\n") + monkeypatch.setenv("GUARD_ENV_FILE", "other.env") + + client = GuardClient(space_id=SPACE) + + assert client.base_url == "https://other" + + +def test_discovery_walks_up_from_subdirectory(monkeypatch): + """ + Ensure the default .env file is found even when the client is run from a nested + directory. + """ + write_env(GUARD_API_KEY=KEY, GUARD_SPACE_ID=SPACE, GUARD_BASE_URL="https://parent") + nested = Path("a/b/c") + nested.mkdir(parents=True) + monkeypatch.chdir(nested) + + client = GuardClient() + + assert client.base_url == "https://parent" + + +def test_numeric_values_are_cast(): + """ + Check that string values from the environment are correctly cast to integers and + floats. + """ + write_env( + GUARD_API_KEY=KEY, + GUARD_SPACE_ID=SPACE, + GUARD_TIMEOUT="12.5", + GUARD_MAX_RETRIES="7", + ) + + client = GuardClient() + + assert client._config.timeout == 12.5 + assert client._config.max_retries == 7 + + +@pytest.mark.parametrize( + ("variable", "value"), + [("GUARD_TIMEOUT", "abc"), ("GUARD_MAX_RETRIES", "3.5")], +) +def test_bad_numeric_value_raises_naming_the_variable(variable, value): + """ + Ensure that invalid numeric formats raise an error naming the problematic variable. + """ + write_env(GUARD_API_KEY=KEY, GUARD_SPACE_ID=SPACE, **{variable: value}) + + with pytest.raises(GuardError, match=variable): + GuardClient() + + +def test_cast_error_names_the_source(): + """ + Verify that cast errors include the source of the variable for easier debugging. + """ + write_env(GUARD_API_KEY=KEY, GUARD_SPACE_ID=SPACE, GUARD_TIMEOUT="abc") + + with pytest.raises(GuardError, match=r"\.env"): + GuardClient() + + +def test_blank_value_falls_through_rather_than_becoming_empty(monkeypatch): + """ + Ensure a bare variable assignment in .env falls through instead of producing an + empty string. + + A bare `GUARD_API_KEY=` must not produce an empty bearer token. + """ + write_env(GUARD_API_KEY="", GUARD_BASE_URL="https://from-file") + monkeypatch.setenv("GUARD_API_KEY", "from-env") + + client = GuardClient(space_id=SPACE) + + assert client._config.api_key == "from-env" + + +def test_blank_value_with_no_fallback_is_treated_as_missing(): + """ + Verify that a blank variable with no fallback correctly triggers a missing required + parameter error. + """ + write_env(GUARD_API_KEY="", GUARD_SPACE_ID=SPACE) + + with pytest.raises(GuardError, match="API key is required"): + GuardClient() + + +def test_engine_from_dotenv_needs_no_api_key(): + """ + Check that configuring the local engine via .env successfully bypasses the API key + requirement. + """ + write_env(GUARD_ENGINE="local") + + client = GuardClient() + + assert client.engine is Engine.LOCAL + + +def test_invalid_engine_from_env_is_rejected(): + """ + Ensure an invalid engine configuration in the environment raises a clear error. + """ + write_env(GUARD_API_KEY=KEY, GUARD_SPACE_ID=SPACE, GUARD_ENGINE="quantum") + + with pytest.raises(GuardError, match=r"Invalid engine='quantum'"): + GuardClient() + + +def test_missing_key_error_mentions_dotenv(): + """ + Verify that the error for a missing API key mentions the .env file as a solution. + """ + with pytest.raises(GuardError, match=r"\.env"): + GuardClient(space_id=SPACE) + + +def test_env_source_values_are_a_copy(): + """ + Ensure EnvSource returns a copy of its values to prevent accidental external + mutation. + """ + write_env(GUARD_API_KEY=KEY) + source = EnvSource() + + source.values["GUARD_API_KEY"] = "mutated" + + assert source.values["GUARD_API_KEY"] == KEY + + +def test_env_source_get_returns_default_when_unset(): + """ + Check that EnvSource.get correctly returns the provided default when a value is + absent. + """ + assert ( + EnvSource(env_file=None).get("GUARD_NOTHING", default="fallback") == "fallback" + ) + + +def test_read_env_file_drops_blank_entries(): + """Verify that read_env_file drops empty entries to allow fallbacks to trigger.""" + write_env(GUARD_API_KEY="", GUARD_LOCALE="de") + + values = read_env_file() + + assert "GUARD_API_KEY" not in values + assert values["GUARD_LOCALE"] == "de" diff --git a/tests/test_estimate.py b/tests/test_estimate.py new file mode 100644 index 0000000..90ad84c --- /dev/null +++ b/tests/test_estimate.py @@ -0,0 +1,226 @@ +""" +Tests for client.estimate_tokens(): probing, multiplier lookup and overrides. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from guard_client import GuardClient, GuardError + +from .conftest import API_KEY, BASE_URL, SPACE_ID, png_bytes, space_detail_response + +SPACE_URL = f"{BASE_URL}/api/v1/spaces/{SPACE_ID}" + + +def mp4_bytes(width: int, height: int, duration: float) -> bytes: + """Reuse the ISO-BMFF builder the probe tests already exercise.""" + from .test_probe import mp4_with + + return mp4_with(width, height, duration=duration) + + +@respx.mock +def test_multiplier_comes_from_the_space(client): + """Verify that the multiplier is fetched from the space details.""" + route = respx.get(SPACE_URL).mock( + return_value=httpx.Response(200, json=space_detail_response()) + ) + + est = client.estimate_tokens(frames=1, width=800, height=600) + + assert route.called + assert est.multiplier == 3 + assert est.tokens == 3 # 1 frame x 1 x 3 + + +@respx.mock +def test_explicit_multiplier_makes_it_offline(client): + """Ensure that providing an explicit multiplier avoids any network calls.""" + route = respx.get(SPACE_URL).mock(return_value=httpx.Response(200)) + + est = client.estimate_tokens(frames=10, width=1920, height=1080, multiplier=2) + + assert not route.called + assert est.tokens == 20 + + +@respx.mock +def test_per_call_space_id_overrides_the_default(client): + """Verify that passing a space ID to the call overrides the default space.""" + other = "99999999-9999-9999-9999-999999999999" + route = respx.get(f"{BASE_URL}/api/v1/spaces/{other}").mock( + return_value=httpx.Response( + 200, json=space_detail_response(predictor_multiplier=5) + ) + ) + + est = client.estimate_tokens(frames=1, width=800, height=600, space_id=other) + + assert route.called + assert est.multiplier == 5 + + +@respx.mock +def test_missing_multiplier_and_space_raises_before_any_call(): + """ + Ensure an error is raised before network activity if both multiplier and space are + missing. + """ + route = respx.get(SPACE_URL).mock(return_value=httpx.Response(200)) + bare = GuardClient(api_key=API_KEY, base_url=BASE_URL) + try: + with pytest.raises(GuardError, match="multiplier is required"): + bare.estimate_tokens(frames=1, width=800, height=600) + finally: + bare.close() + + assert not route.called + + +@respx.mock +def test_space_without_multiplier_raises(client): + """Verify that a space lacking a predictor multiplier raises an error.""" + respx.get(SPACE_URL).mock( + return_value=httpx.Response( + 200, json=space_detail_response(predictor_multiplier=None) + ) + ) + + with pytest.raises(GuardError, match="no predictor_multiplier"): + client.estimate_tokens(frames=1, width=800, height=600) + + +@respx.mock +def test_probes_a_video(client): + """ + Ensure that the token estimator correctly probes a video file for dimensions and + duration. + """ + respx.get(SPACE_URL).mock( + return_value=httpx.Response( + 200, json=space_detail_response(predictor_multiplier=1) + ) + ) + + est = client.estimate_tokens(mp4_bytes(2560, 1440, 10.4), filename="clip.mp4") + + assert est.frames == 11 # 10.4s rounded up + assert (est.width, est.height) == (2560, 1440) + assert est.tier_cost == 2 + assert est.tokens == 22 + + +@respx.mock +def test_probes_an_image(client): + """Ensure that the token estimator correctly probes an image file.""" + respx.get(SPACE_URL).mock( + return_value=httpx.Response( + 200, json=space_detail_response(predictor_multiplier=1) + ) + ) + + est = client.estimate_tokens(png_bytes(), filename="x.png") + + assert est.frames == 1 + assert est.tokens == 1 + + +@respx.mock +def test_explicit_values_override_the_probe(client): + """ + Verify that explicitly provided values override those found by probing the media. + """ + respx.get(SPACE_URL).mock( + return_value=httpx.Response( + 200, json=space_detail_response(predictor_multiplier=1) + ) + ) + + est = client.estimate_tokens( + mp4_bytes(1280, 720, 10.0), filename="clip.mp4", duration_seconds=60.0 + ) + + assert est.frames == 60 # the override, not the probed 10 + assert (est.width, est.height) == (1280, 720) # still from the file + + +@respx.mock +def test_explicit_frames_beat_duration(client): + """Ensure that an explicit frame count takes precedence over duration.""" + respx.get(SPACE_URL).mock( + return_value=httpx.Response( + 200, json=space_detail_response(predictor_multiplier=1) + ) + ) + + est = client.estimate_tokens( + mp4_bytes(1280, 720, 10.0), filename="clip.mp4", frames=3 + ) + + assert est.frames == 3 + + +def test_no_source_and_no_dimensions_raises(client): + """ + Verify that an error is raised if neither a media source nor dimensions are + provided. + """ + with pytest.raises(GuardError, match="Nothing to estimate from"): + client.estimate_tokens(multiplier=1) + + +@respx.mock +def test_pure_calculation_touches_nothing(client): + """ + Ensure that supplying all required values manually skips probing and network + requests entirely. + """ + route = respx.get(SPACE_URL).mock(return_value=httpx.Response(200)) + + est = client.estimate_tokens(frames=10, width=3840, height=2160, multiplier=3) + + assert not route.called + assert est.tokens == 120 + + +@respx.mock +def test_above_top_tier_raises(client): + """Verify that dimensions exceeding the maximum tier raise an error.""" + respx.get(SPACE_URL).mock( + return_value=httpx.Response(200, json=space_detail_response()) + ) + + with pytest.raises(GuardError, match="exceeds the largest tier"): + client.estimate_tokens(frames=1, width=4096, height=2160, multiplier=1) + + +@respx.mock +async def test_async_estimate_fetches_multiplier(async_client): + """Ensure the async client fetches the multiplier from the space details.""" + route = respx.get(SPACE_URL).mock( + return_value=httpx.Response(200, json=space_detail_response()) + ) + + est = await async_client.estimate_tokens(frames=2, width=1920, height=1080) + + assert route.called + assert est.tokens == 6 # 2 x 1 x 3 + + +@respx.mock +async def test_async_estimate_offline_with_multiplier(async_client): + """ + Verify that the async client avoids network calls when given an explicit multiplier. + """ + route = respx.get(SPACE_URL).mock(return_value=httpx.Response(200)) + + est = await async_client.estimate_tokens( + mp4_bytes(1920, 1080, 5.0), filename="clip.mp4", multiplier=4 + ) + + assert not route.called + assert est.frames == 5 + assert est.tokens == 20 diff --git a/tests/test_local.py b/tests/test_local.py new file mode 100644 index 0000000..d80a534 --- /dev/null +++ b/tests/test_local.py @@ -0,0 +1,622 @@ +""" +Tests for the optional local-engine wrapper. + +The real engine (`guard-local-detector`) is not required here. Everything is driven +through a fake satisfying the `LocalEngine` protocol, so the suite runs without the +ONNX runtime installed. +""" + +from __future__ import annotations + +import builtins +import subprocess +import sys + +import pytest + +from guard_client import ( + Engine, + GuardClient, + GuardError, + GuardLocalEngineError, + GuardLocalModelError, + GuardMediaDecodeError, + LocalEngineNotInstalledError, + UnsupportedMediaTypeError, +) +from guard_client.local import LocalRunner, _adapt, _score_to_int + +from .conftest import png_bytes + + +def _stand_in(name: str, *bases: type) -> type: + """ + Forge a guard_local exception class without importing the AGPL engine. + + The mapping matches on the MRO by module and class name, so the stand-ins must claim + the `guard_local` module to be recognized. Building them here rather than importing + the real ones allows these tests to run without the `[local]` extra installed. + """ + klass = type(name, bases or (Exception,), {"__doc__": f"Stand-in for {name}."}) + klass.__module__ = "guard_local.exceptions" + return klass + + +StandInGuardLocalError = _stand_in("GuardLocalError") +StandInModelLoadError = _stand_in("ModelLoadError", StandInGuardLocalError) +StandInUnsupportedMediaError = _stand_in( + "UnsupportedMediaError", StandInGuardLocalError, ValueError +) +StandInMediaDecodeError = _stand_in( + "MediaDecodeError", StandInGuardLocalError, ValueError +) +StandInUnknownError = _stand_in("SomeFutureError", StandInGuardLocalError) +ImpostorModelLoadError = type("ModelLoadError", (Exception,), {}) + + +class RaisingEngine: + """An engine whose only behavior is to fail with a chosen exception.""" + + def __init__(self, exc: BaseException): + self.exc = exc + + def analyze(self, data: bytes, media_type: str): + """Raise the configured exception for a synchronous analysis call.""" + raise self.exc + + async def analyze_async(self, data: bytes, media_type: str): + """Raise the configured exception for an asynchronous analysis call.""" + raise self.exc + + +class SyncOnlyRaisingEngine: + """ + An engine that fails only during synchronous analysis. + + This is used to test failures occurring within a thread offload where + there is no native async entry point. + """ + + def __init__(self, exc: BaseException): + self.exc = exc + + def analyze(self, data: bytes, media_type: str): + """Raise the configured exception for a synchronous analysis call.""" + raise self.exc + + +class FakeEngine: + """Minimal stand-in for guard_local.LocalDetectorEngine.""" + + def __init__(self, payload=None): + self.payload = ( + payload if payload is not None else {"status": "safe", "score": 0.87} + ) + self.calls = [] + + def analyze(self, data: bytes, media_type: str): + """Record the call and return the mocked payload synchronously.""" + self.calls.append((len(data), media_type)) + return self.payload + + async def analyze_async(self, data: bytes, media_type: str): + """Record the call and return the mocked payload asynchronously.""" + self.calls.append((len(data), media_type)) + return self.payload + + +class SyncOnlyEngine: + """An engine that predates the async API to exercise the thread offload.""" + + def __init__(self): + self.calls = 0 + + def analyze(self, data: bytes, media_type: str): + """Record the call and return a fixed analysis payload.""" + self.calls += 1 + return {"label": "violence", "score": 0.4} + + +def test_analyze_returns_unified_result(): + """ + Verify that a local analysis returns the unified standard `DetectionResult` shape. + """ + runner = LocalRunner(engine=FakeEngine()) + + result = runner.analyze(png_bytes()) + + assert result.engine is Engine.LOCAL + assert result.activity_id is None # local runs create no activity + assert len(result.results) == 1 + assert result.results[0].label == "safe" + assert result.results[0].score == 87 + + +def test_analyze_passes_resolved_media_type(): + """Ensure the resolved MIME type is passed through to the engine.""" + engine = FakeEngine() + runner = LocalRunner(engine=engine) + + runner.analyze(png_bytes()) + + assert engine.calls == [(len(png_bytes()), "image/png")] + + +def test_engine_is_constructed_once(): + """ + Verify the engine is cached across calls since ONNX session loads are expensive. + """ + engine = FakeEngine() + runner = LocalRunner(engine=engine) + + runner.analyze(png_bytes()) + runner.analyze(png_bytes()) + + assert runner._engine is engine + assert len(engine.calls) == 2 + + +async def test_analyze_async_uses_native_async(): + """ + Ensure async analysis utilizes the native async entry point when available. + """ + runner = LocalRunner(engine=FakeEngine()) + + result = await runner.analyze_async(png_bytes()) + + assert result.engine is Engine.LOCAL + assert result.results[0].score == 87 + + +async def test_analyze_async_offloads_sync_engine(): + """ + Verify async analysis safely offloads an older sync-only engine to a separate + thread. + """ + engine = SyncOnlyEngine() + runner = LocalRunner(engine=engine) + + result = await runner.analyze_async(png_bytes()) + + assert engine.calls == 1 + assert result.results[0].label == "violence" + assert result.results[0].score == 40 + + +def test_client_routes_to_local_engine(): + """ + Verify a client configured for the local engine correctly routes analysis calls. + """ + client = GuardClient(engine="local") + client._local = LocalRunner(engine=FakeEngine()) + + result = client.analyze(png_bytes()) + + assert result.engine is Engine.LOCAL + assert result.activity_id is None + + +def test_per_call_engine_override_to_local(): + """ + Ensure a cloud-configured client can explicitly request a local analysis without + network access. + """ + client = GuardClient(api_key="k", space_id="11111111-1111-1111-1111-111111111111") + client._local = LocalRunner(engine=FakeEngine()) + + result = client.analyze(png_bytes(), engine="local") + + assert result.engine is Engine.LOCAL + + +async def test_async_client_routes_to_local_engine(): + """ + Verify an async client configured for the local engine routes correctly. + """ + from guard_client import AsyncGuardClient + + client = AsyncGuardClient(engine="local") + client._local = LocalRunner(engine=FakeEngine()) + + result = await client.analyze(png_bytes()) + + assert result.engine is Engine.LOCAL + + +def test_missing_extra_raises_actionable_error(monkeypatch): + """ + Ensure that missing the local extra package raises an error with installation + instructions. + """ + monkeypatch.setitem(sys.modules, "guard_local", None) + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "guard_local": + raise ImportError("No module named 'guard_local'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + runner = LocalRunner() + with pytest.raises(LocalEngineNotInstalledError, match=r"guard-client\[local\]"): + runner.analyze(png_bytes()) + + +def test_broken_install_is_distinguished_from_missing(monkeypatch): + """ + Ensure an installed but unimportable engine raises a clear error rather than + reporting it missing. + """ + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "guard_local": + raise ImportError( + "cannot import name 'analyze_file' from 'guard_local.engine'" + ) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + runner = LocalRunner() + with pytest.raises(LocalEngineNotInstalledError, match="could not be imported"): + runner.analyze(png_bytes()) + + +def test_missing_transitive_dependency_is_reported(monkeypatch): + """ + Verify a missing onnxruntime is reported as an incomplete install, not a missing + extra. + """ + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "guard_local": + raise ModuleNotFoundError( + "No module named 'onnxruntime'", name="onnxruntime" + ) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + runner = LocalRunner() + with pytest.raises(LocalEngineNotInstalledError, match="'onnxruntime' is not"): + runner.analyze(png_bytes()) + + +def test_engine_without_factory_reports_upgrade(monkeypatch): + """ + Ensure using an older guard_local missing the required entry point prompts the user + to upgrade. + """ + import types + + stub = types.ModuleType("guard_local") + monkeypatch.setitem(sys.modules, "guard_local", stub) + + runner = LocalRunner() + with pytest.raises( + LocalEngineNotInstalledError, match="does not expose LocalDetectorEngine" + ): + runner.analyze(png_bytes()) + + +def test_import_guard_client_does_not_import_guard_local(): + """ + Verify that merely importing the guard_client package never pulls in the AGPL local + engine. + + Checked in a subprocess, not against this process's sys.modules. With the [local] + extra installed, test_contract.py imports guard_local at collection time, so an + in-process assertion would report whatever the rest of the session did rather than + what importing guard_client does. + """ + proc = subprocess.run( + [ + sys.executable, + "-c", + "import sys, guard_client; " + "assert 'guard_local' not in sys.modules, sorted(sys.modules)", + ], + capture_output=True, + text=True, + ) + + assert proc.returncode == 0, proc.stderr + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (0.0, 0), + (0.5, 50), + (1.0, 100), + (0.874, 87), + (87, 87), # already on the 0-100 scale + (100, 100), + (250, 100), # clamped + (-5, 0), # clamped + ("nonsense", 0), + (None, 0), + ], +) +def test_score_normalisation(raw, expected): + """ + Check that varying raw engine score formats correctly normalize to a 0-100 integer + range. + """ + assert _score_to_int(raw) == expected + + +def test_adapt_handles_list_of_entries(): + """Verify the adapter correctly processes a sequence of analysis entries.""" + items = _adapt([{"label": "a", "score": 0.1}, {"label": "b", "score": 0.9}]) + + assert [i.label for i in items] == ["a", "b"] + assert [i.score for i in items] == [10, 90] + + +def test_adapt_derives_stable_task_ids(): + """ + Ensure derived local task IDs are consistent and reproducible so callers can key off + them. + """ + first = _adapt({"label": "deepfake", "score": 0.5}) + second = _adapt({"label": "deepfake", "score": 0.9}) + + assert first[0].task_id == second[0].task_id + + +def test_adapt_honours_supplied_task_id(): + """ + Verify the adapter preserves an explicit task ID if one is supplied by the engine. + """ + task_id = "44444444-4444-4444-4444-444444444444" + items = _adapt({"label": "x", "score": 1, "task_id": task_id}) + + assert str(items[0].task_id) == task_id + + +def test_adapt_tolerates_empty_and_junk(): + """Check that the adapter gracefully skips empty, invalid, or malformed entries.""" + assert _adapt(None) == [] + assert _adapt([]) == [] + assert _adapt(["not a dict"]) == [] + + +MAPPINGS = [ + (StandInUnsupportedMediaError, UnsupportedMediaTypeError), + (StandInMediaDecodeError, GuardMediaDecodeError), + (StandInModelLoadError, GuardLocalModelError), + (StandInGuardLocalError, GuardLocalEngineError), + # anything the engine adds later still lands on the catch-all base + (StandInUnknownError, GuardLocalEngineError), +] + +MAPPING_IDS = [raised.__name__ for raised, _ in MAPPINGS] + + +@pytest.mark.parametrize(("raised", "expected"), MAPPINGS, ids=MAPPING_IDS) +def test_engine_errors_are_mapped(raised, expected): + """ + Ensure every internal guard_local failure translates into the correct public + GuardError. + """ + runner = LocalRunner(engine=RaisingEngine(raised("boom"))) + + with pytest.raises(expected): + runner.analyze(png_bytes()) + + +@pytest.mark.parametrize(("raised", "expected"), MAPPINGS, ids=MAPPING_IDS) +async def test_engine_errors_are_mapped_async(raised, expected): + """ + Ensure internal guard_local failures translate correctly during async operations. + """ + runner = LocalRunner(engine=RaisingEngine(raised("boom"))) + + with pytest.raises(expected): + await runner.analyze_async(png_bytes()) + + +@pytest.mark.parametrize(("raised", "expected"), MAPPINGS, ids=MAPPING_IDS) +async def test_engine_errors_are_mapped_through_the_thread_offload(raised, expected): + """ + Ensure error translations survive when a sync engine fails inside a thread offload. + """ + runner = LocalRunner(engine=SyncOnlyRaisingEngine(raised("boom"))) + + with pytest.raises(expected): + await runner.analyze_async(png_bytes()) + + +def test_mapped_errors_are_all_guard_errors(): + """ + Verify that all mapped errors correctly derive from the base GuardError. + + This ensures a single except clause safely catches both cloud and local failures. + """ + for raised, _ in MAPPINGS: + runner = LocalRunner(engine=RaisingEngine(raised("boom"))) + + with pytest.raises(GuardError): + runner.analyze(png_bytes()) + + +def test_bad_input_errors_stay_value_errors(): + """ + Verify undecodable or unscoreable media exceptions correctly subclass ValueError. + """ + for raised in (StandInMediaDecodeError, StandInUnsupportedMediaError): + runner = LocalRunner(engine=RaisingEngine(raised("boom"))) + + with pytest.raises(ValueError): + runner.analyze(png_bytes()) + + +def test_mapping_preserves_the_original_as_cause(): + """ + Ensure that translated exceptions preserve the original engine error as their + `__cause__`. + """ + runner = LocalRunner(engine=RaisingEngine(StandInModelLoadError("model.onnx"))) + + with pytest.raises(GuardLocalModelError) as excinfo: + runner.analyze(png_bytes()) + + assert isinstance(excinfo.value.__cause__, StandInModelLoadError) + assert "model.onnx" in str(excinfo.value) + + +def test_non_engine_exceptions_propagate_unchanged(): + """ + Ensure generic Python bugs like a RuntimeError pass through untouched without + wrapping. + """ + runner = LocalRunner(engine=RaisingEngine(RuntimeError("segfault-ish"))) + + with pytest.raises(RuntimeError, match="segfault-ish"): + runner.analyze(png_bytes()) + + +def test_a_same_named_error_from_elsewhere_is_not_mapped(): + """ + Verify that matching relies on module paths so another library's exception is not + falsely relabeled. + """ + runner = LocalRunner(engine=RaisingEngine(ImpostorModelLoadError("not ours"))) + + with pytest.raises(ImpostorModelLoadError): + runner.analyze(png_bytes()) + + +def test_constructor_failures_are_mapped_too(monkeypatch): + """ + Ensure exception mapping functions properly when the error occurs during engine + initialization. + """ + import types + + stub = types.ModuleType("guard_local") + + def factory(*args, **kwargs): + raise StandInModelLoadError("no model on disk") + + stub.LocalDetectorEngine = factory + monkeypatch.setitem(sys.modules, "guard_local", stub) + + runner = LocalRunner() + with pytest.raises(GuardLocalModelError, match="no model on disk"): + runner.analyze(png_bytes()) + + +MATCH = { + "id": "c2pa.generative", + "category": "aiGenerated", + "label": "Generative tool in manifest", + "description": "The signed manifest names an AI tool.", + "confidence": 92, + "kind": None, + "evidence": "c2pa.actions: com.adobe.firefly", + "source": "c2pa", +} + + +def test_adapt_carries_the_engines_evidence(): + """ + Verify the adapter successfully extracts and forwards rich matching evidence from + the engine. + + The evidence is the primary reason to run locally. Dropping it would waste the + engine's value. + """ + items = _adapt( + {"label": "AI-Generated", "score": 0.92, "detected": True, "matches": [MATCH]} + ) + + assert items[0].detected is True + assert len(items[0].matches) == 1 + assert items[0].matches[0].id == "c2pa.generative" + assert items[0].matches[0].source == "c2pa" + assert items[0].matches[0].confidence == 92 + + +def test_adapt_leaves_evidence_unset_when_the_engine_reports_none(): + """ + Ensure that older engine outputs without evidence result in `None` rather than + fabricated structures. + """ + items = _adapt({"label": "Violence", "score": 0.1}) + + assert items[0].detected is None + assert items[0].matches is None + + +def test_adapt_distinguishes_no_evidence_from_no_answer(): + """ + Verify an empty list signifies an evaluated negative outcome while `None` means + omitted entirely. + """ + items = _adapt( + {"label": "Explicit", "score": 0.0, "detected": False, "matches": []} + ) + + assert items[0].detected is False + assert items[0].matches == [] + + +def test_adapt_tolerates_junk_evidence(): + """ + Check that the adapter skips over malformed evidence entries while preserving valid + ones. + """ + items = _adapt( + { + "label": "AI-Generated", + "score": 0.5, + "detected": "yes please", + "matches": ["not a dict", {"missing": "everything"}, MATCH], + } + ) + + assert items[0].detected is None # not a bool, so no verdict is claimed + assert [m.id for m in items[0].matches] == ["c2pa.generative"] + + +def test_adapt_ignores_matches_that_are_not_a_list(): + """ + Ensure the adapter safely ignores a `matches` structure if it is not formed as a + list. + """ + items = _adapt({"label": "Violence", "score": 0.5, "matches": "nonsense"}) + + assert items[0].matches is None + + +def test_client_surfaces_evidence_end_to_end(): + """ + Verify evidence extracted by the local engine remains accessible end-to-end on the + final object. + """ + payload = [ + {"label": "AI-Generated", "score": 0.92, "detected": True, "matches": [MATCH]} + ] + client = GuardClient(engine="local") + client._local = LocalRunner(engine=FakeEngine(payload)) + + result = client.analyze(png_bytes()) + + assert result.results[0].matches[0].evidence.endswith("firefly") + + +def test_missing_extra_is_still_a_local_engine_error(): + """ + Ensure an uninstalled engine correctly subclasses the full exception hierarchy. + + Reparenting must not accidentally narrow the scope of what the original except + clauses caught. + """ + assert issubclass(LocalEngineNotInstalledError, GuardLocalEngineError) + assert issubclass(LocalEngineNotInstalledError, ImportError) + assert issubclass(LocalEngineNotInstalledError, GuardError) diff --git a/tests/test_media.py b/tests/test_media.py new file mode 100644 index 0000000..633dea9 --- /dev/null +++ b/tests/test_media.py @@ -0,0 +1,159 @@ +""" +Tests for media resolution: bytes, paths, file objects and MIME detection. +""" + +from __future__ import annotations + +import io + +import pytest + +from guard_client import MediaType, UnsupportedMediaTypeError +from guard_client.media import SUPPORTED_MEDIA_TYPES, resolve_media + +from .conftest import jpeg_bytes, png_bytes + + +def test_resolves_path(png): + """Verify that media is correctly resolved from a pathlib.Path object.""" + data, media_type, name = resolve_media(png) + + assert data == png_bytes() + assert media_type is MediaType.PNG + assert name == "sample.png" + + +def test_resolves_path_as_string(png): + """Verify that media is correctly resolved from a string file path.""" + _, media_type, _ = resolve_media(str(png)) + assert media_type is MediaType.PNG + + +def test_resolves_raw_bytes_by_sniffing(): + """Ensure detection falls back to magic bytes when no filename is provided.""" + _, media_type, name = resolve_media(png_bytes()) + + assert media_type is MediaType.PNG + assert name.endswith(".png") + + +def test_resolves_file_object(png): + """Verify that an open binary file object is correctly read and resolved.""" + with open(png, "rb") as handle: + data, media_type, name = resolve_media(handle) + + assert data == png_bytes() + assert media_type is MediaType.PNG + assert name == "sample.png" + + +def test_resolves_bytesio_with_explicit_filename(): + """ + Verify that a BytesIO stream is resolved using the explicitly provided filename. + """ + stream = io.BytesIO(jpeg_bytes()) + _, media_type, name = resolve_media(stream, filename="photo.jpg") + + assert media_type is MediaType.JPEG + assert name == "photo.jpg" + + +def test_explicit_media_type_skips_detection(): + """ + Ensure an explicit type overrides detection even if the bytes suggest otherwise. + """ + _, media_type, _ = resolve_media(png_bytes(), media_type="image/webp") + assert media_type is MediaType.WEBP + + +def test_explicit_media_type_accepts_enum(): + """Ensure that an explicit MediaType enum is correctly accepted and applied.""" + _, media_type, _ = resolve_media(png_bytes(), media_type=MediaType.JPEG) + assert media_type is MediaType.JPEG + + +@pytest.mark.parametrize( + ("filename", "expected"), + [ + ("clip.mp4", MediaType.MP4), + ("clip.webm", MediaType.WEBM), + ("clip.mov", MediaType.QUICKTIME), + ("photo.heic", MediaType.HEIC), + ("photo.webp", MediaType.WEBP), + ("photo.gif", MediaType.GIF), + ], +) +def test_extension_detection(filename, expected): + """Verify that media types are correctly inferred from standard file extensions.""" + _, media_type, _ = resolve_media(b"\x00" * 32, filename=filename) + assert media_type is expected + + +def test_sniffs_webp_riff_container(): + """ + Ensure that WEBP files are correctly identified via their RIFF container magic + bytes. + """ + data = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP" + b"\x00" * 16 + _, media_type, _ = resolve_media(data) + assert media_type is MediaType.WEBP + + +def test_sniffs_mp4_ftyp_box(): + """Ensure that MP4 files are correctly identified via their ftyp box.""" + data = b"\x00\x00\x00\x18" + b"ftyp" + b"isom" + b"\x00" * 16 + _, media_type, _ = resolve_media(data) + assert media_type is MediaType.MP4 + + +def test_sniffs_heic_by_brand(): + """Ensure that HEIC files are correctly identified via their specific ftyp brand.""" + data = b"\x00\x00\x00\x18" + b"ftyp" + b"heic" + b"\x00" * 16 + _, media_type, _ = resolve_media(data) + assert media_type is MediaType.HEIC + + +def test_rejects_unsupported_type(): + """Verify that an unsupported file extension raises an appropriate error.""" + with pytest.raises(UnsupportedMediaTypeError, match="Unsupported media type"): + resolve_media(b"%PDF-1.4 fake", filename="doc.pdf") + + +def test_rejects_undetectable_bytes(): + """Verify that unrecognizable raw bytes raise an appropriate error.""" + with pytest.raises(UnsupportedMediaTypeError, match="Could not determine"): + resolve_media(b"\x01\x02\x03\x04 not a known format") + + +def test_rejects_empty_input(): + """Verify that empty byte inputs are cleanly rejected.""" + with pytest.raises(UnsupportedMediaTypeError, match="empty"): + resolve_media(b"") + + +def test_rejects_text_mode_file(tmp_path): + """Ensure that files opened in text mode are rejected with a clear error message.""" + path = tmp_path / "note.png" + path.write_text("not binary") + # text mode is the mistake under test + with ( + open(path) as handle, + pytest.raises(UnsupportedMediaTypeError, match="binary mode"), + ): + resolve_media(handle) + + +def test_missing_file_raises_filenotfound(tmp_path): + """ + Verify that providing a non-existent file path naturally raises a FileNotFoundError. + """ + with pytest.raises(FileNotFoundError): + resolve_media(tmp_path / "absent.png") + + +def test_supported_types_match_enum(): + """ + Ensure the supported types frozenset stays perfectly synchronized with the MediaType + enum. + """ + assert {m.value for m in MediaType} == SUPPORTED_MEDIA_TYPES diff --git a/tests/test_predictors.py b/tests/test_predictors.py new file mode 100644 index 0000000..71bf4d0 --- /dev/null +++ b/tests/test_predictors.py @@ -0,0 +1,194 @@ +""" +Tests for the predictors resource. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from guard_client import GuardError, MediaCategory, PredictorOrder, PredictorStatus + +from .conftest import ( + BASE_URL, + ORG_ID, + PREDICTOR_ID, + TASK_ID, + page_response, + predictor_response, +) + +PREDICTORS_URL = f"{BASE_URL}/api/v1/predictors/" + + +@respx.mock +def test_list_parses_predictors(client): + """ + Verify that the list endpoint correctly parses predictor attributes from the + response. + """ + respx.get(PREDICTORS_URL).mock( + return_value=httpx.Response( + 200, json=page_response([predictor_response()], count=3) + ) + ) + + page = client.predictors.list() + + assert page.count == 3 + predictor = page[0] + assert predictor.id == PREDICTOR_ID + assert predictor.name == "Default Predictor" + assert predictor.status is PredictorStatus.ACTIVE + assert predictor.token_multiplier == 1 + assert predictor.supported_media == [MediaCategory.IMAGE, MediaCategory.VIDEO] + assert predictor.supported_task_ids == [TASK_ID] + + +@respx.mock +def test_list_passes_filters(client): + """ + Ensure that all provided filters are correctly formatted and sent in the query + string. + """ + route = respx.get(PREDICTORS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.predictors.list( + organization_id=ORG_ID, + supported_task_ids=[TASK_ID], + sort_by="name", + sort_order="desc", + skip=5, + limit=10, + ) + + params = route.calls.last.request.url.params + assert params["organization_id"] == str(ORG_ID) + assert params.get_list("supported_task_ids") == [str(TASK_ID)] + assert params["sort_by"] == "name" + assert params["sort_order"] == "desc" + assert params["skip"] == "5" + assert params["limit"] == "10" + + +@respx.mock +def test_list_omits_unset_filters(client): + """ + Check that parameters not explicitly provided are excluded from the request URL. + """ + route = respx.get(PREDICTORS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.predictors.list() + + params = route.calls.last.request.url.params + for absent in ("user_id", "organization_id", "supported_task_ids", "sort_by"): + assert absent not in params + + +@respx.mock +def test_list_accepts_both_owner_filters(client): + """ + Verify that filtering by both user and organization simultaneously is accepted. + """ + route = respx.get(PREDICTORS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.predictors.list(user_id=TASK_ID, organization_id=ORG_ID) + + assert route.called + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"sort_by": "bogus"}, r"Invalid sort_by='bogus'.*'name', 'created_at'"), + ({"sort_order": "sideways"}, "Invalid sort_order"), + ({"limit": 0}, "Invalid limit=0"), + ({"skip": -1}, "Invalid skip=-1"), + ], +) +@respx.mock +def test_list_validates_before_sending(client, kwargs, message): + """ + Ensure that invalid query parameters raise an error locally before any network call. + """ + route = respx.get(PREDICTORS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match=message): + client.predictors.list(**kwargs) + + assert not route.called + + +@respx.mock +def test_enum_member_accepted(client): + """Verify that enum members can be passed directly as filter arguments.""" + route = respx.get(PREDICTORS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.predictors.list(sort_by=PredictorOrder.CREATED_AT) + + assert route.calls.last.request.url.params["sort_by"] == "created_at" + + +@respx.mock +def test_iter_all_walks_pages(client): + """Ensure iter_all correctly paginates through multiple pages of predictors.""" + first = [predictor_response(name=f"p{i}") for i in range(2)] + route = respx.get(PREDICTORS_URL).mock( + side_effect=[ + httpx.Response(200, json=page_response(first, count=3)), + httpx.Response( + 200, json=page_response([predictor_response(name="p2")], count=3) + ), + ] + ) + + names = [p.name for p in client.predictors.iter_all(page_size=2)] + + assert names == ["p0", "p1", "p2"] + assert route.call_count == 2 + + +@respx.mock +async def test_async_list(async_client): + """Verify that the async client can fetch and parse a page of predictors.""" + respx.get(PREDICTORS_URL).mock( + return_value=httpx.Response( + 200, json=page_response([predictor_response()], count=1) + ) + ) + + page = await async_client.predictors.list() + + assert page[0].id == PREDICTOR_ID + + +@respx.mock +async def test_async_iter_all(async_client): + """ + Ensure the async client can correctly paginate through all available predictors. + """ + route = respx.get(PREDICTORS_URL).mock( + side_effect=[ + httpx.Response( + 200, + json=page_response( + [predictor_response(name=f"p{i}") for i in range(2)], 3 + ), + ), + httpx.Response(200, json=page_response([predictor_response(name="p2")], 3)), + ] + ) + + names = [p.name async for p in async_client.predictors.iter_all(page_size=2)] + + assert names == ["p0", "p1", "p2"] + assert route.call_count == 2 diff --git a/tests/test_probe.py b/tests/test_probe.py new file mode 100644 index 0000000..142b184 --- /dev/null +++ b/tests/test_probe.py @@ -0,0 +1,267 @@ +""" +Tests for dependency-free media header parsing. + +Fixtures are built by hand so the expected dimensions are known exactly. The same +parsers were also checked against real ffmpeg-generated media and macOS `sips` during +development. These tests lock in the byte-level layouts. +""" + +from __future__ import annotations + +import struct + +import pytest + +from guard_client import GuardError, MediaType, UnsupportedMediaTypeError, probe_media + +from .conftest import png_bytes + + +def jpeg_with_size(width: int, height: int) -> bytes: + """Build a mock JPEG file where the SOF0 segment is placed after an APP0 segment.""" + app0 = b"\xff\xe0" + struct.pack(">H", 16) + b"JFIF\x00" + b"\x00" * 9 + sof0 = ( + b"\xff\xc0" + + struct.pack(">H", 11) + + b"\x08" + + struct.pack(">HH", height, width) + ) + return b"\xff\xd8" + app0 + sof0 + b"\xff\xd9" + + +def gif_with_size(width: int, height: int) -> bytes: + """Build a mock GIF file with the specified width and height in its header.""" + return b"GIF89a" + struct.pack(" bytes: + """Build a mock lossy WEBP file with the given dimensions.""" + # 3-byte frame tag + 3-byte sync code, then the dimensions + payload = b"\x00" * 6 + struct.pack(" bytes: + """Build a mock lossless WEBP file with the given dimensions.""" + bits = (width - 1) | ((height - 1) << 14) + payload = b"\x2f" + struct.pack(" bytes: + """Build a mock extended WEBP file with the given dimensions.""" + payload = ( + bytes([0x10, 0, 0, 0]) + + (width - 1).to_bytes(3, "little") + + (height - 1).to_bytes(3, "little") + ) + body = b"WEBP" + b"VP8X" + struct.pack(" bytes: + """Wrap a payload in an ISO-BMFF box structure.""" + return struct.pack(">I", len(payload) + 8) + box_type + payload + + +def mp4_with( + width: int, height: int, *, duration: float, timescale: int = 1000 +) -> bytes: + """Build a minimal ISO-BMFF file containing only the boxes the parser reads.""" + mvhd = _box( + b"mvhd", + b"\x00\x00\x00\x00" # version 0 + flags + + b"\x00" * 8 # creation / modification time + + struct.pack(">I", timescale) + + struct.pack(">I", int(duration * timescale)) + + b"\x00" * 80, + ) + # tkhd v0: width/height are the last 8 bytes of an 84-byte payload, 16.16 fixed + tkhd_payload = bytearray(b"\x00" * 84) + tkhd_payload[76:84] = struct.pack(">II", width << 16, height << 16) + trak = _box(b"trak", _box(b"tkhd", bytes(tkhd_payload))) + return _box(b"ftyp", b"isom" + b"\x00" * 8) + _box(b"moov", mvhd + trak) + + +def heic_with(width: int, height: int) -> bytes: + """Build a mock HEIC file that carries a thumbnail ispe alongside the main one.""" + thumb = _box(b"ispe", b"\x00\x00\x00\x00" + struct.pack(">II", 320, 240)) + full = _box(b"ispe", b"\x00\x00\x00\x00" + struct.pack(">II", width, height)) + ipco = _box(b"ipco", thumb + full) + iprp = _box(b"iprp", ipco) + # `meta` is a FullBox: 4 bytes of version+flags before its children. + meta = _box(b"meta", b"\x00\x00\x00\x00" + iprp) + return _box(b"ftyp", b"heic" + b"\x00" * 8) + meta + + +def _ebml(element_id: bytes, payload: bytes) -> bytes: + """Wrap a payload in an EBML element structure.""" + size = len(payload) + if size < 0x7F: + length = bytes([0x80 | size]) + else: + length = b"\x40" + struct.pack(">H", size)[1:] if size < 0x3FFF else b"" + length = bytes([0x40 | (size >> 8), size & 0xFF]) + return element_id + length + payload + + +def webm_with(width: int, height: int, *, duration: float) -> bytes: + """Build a minimal Matroska segment with Info and Tracks elements for parsing.""" + timecode_scale = _ebml(b"\x2a\xd7\xb1", struct.pack(">I", 1_000_000)) + duration_el = _ebml(b"\x44\x89", struct.pack(">d", duration * 1000.0)) + info = _ebml(b"\x15\x49\xa9\x66", timecode_scale + duration_el) + + video = _ebml( + b"\xe0", + _ebml(b"\xb0", struct.pack(">H", width)) + + _ebml(b"\xba", struct.pack(">H", height)), + ) + tracks = _ebml(b"\x16\x54\xae\x6b", _ebml(b"\xae", video)) + return _ebml(b"\x18\x53\x80\x67", info + tracks) + + +def test_png(): + """Verify the probe correctly extracts dimensions and duration from a PNG file.""" + info = probe_media(png_bytes(), filename="x.png") + + assert info.media_type is MediaType.PNG + assert (info.width, info.height) == (1, 1) + assert info.frames == 1 + assert info.duration_seconds == 0.0 + + +def test_jpeg_walks_past_app0(): + """ + Ensure the JPEG parser correctly walks past the APP0 segment to find the SOF0 + dimensions. + """ + info = probe_media(jpeg_with_size(800, 600), filename="x.jpg") + + assert info.media_type is MediaType.JPEG + assert (info.width, info.height) == (800, 600) + + +def test_gif(): + """Verify the probe correctly extracts dimensions from a GIF file header.""" + info = probe_media(gif_with_size(320, 240), filename="x.gif") + assert (info.width, info.height) == (320, 240) + + +@pytest.mark.parametrize( + ("builder", "width", "height"), + [ + (webp_lossy, 640, 480), + (webp_lossless, 500, 400), + (webp_extended, 4000, 3000), + ], +) +def test_webp_variants(builder, width, height): + """ + Ensure the probe correctly reads dimensions from all three variants of WEBP files. + """ + info = probe_media(builder(width, height), filename="x.webp") + assert (info.width, info.height) == (width, height) + + +def test_mp4_dimensions_and_duration(): + """ + Verify the probe correctly extracts dimensions and calculates the duration of an + MP4 file. + """ + info = probe_media(mp4_with(1280, 720, duration=10.4), filename="x.mp4") + + assert (info.width, info.height) == (1280, 720) + assert info.duration_seconds == pytest.approx(10.4, abs=0.01) + assert info.frames == 11 # rounded up + + +def test_mp4_portrait_keeps_orientation(): + """ + Ensure the probe reads dimensions directly without modifying them for rotation + metadata. + """ + info = probe_media(mp4_with(1080, 1920, duration=3.0), filename="x.mp4") + + assert (info.width, info.height) == (1080, 1920) + assert info.long_side == 1920 + + +def test_quicktime_uses_the_same_parser(): + """ + Verify that QuickTime (MOV) files are parsed successfully using the ISO-BMFF parser. + """ + info = probe_media(mp4_with(640, 480, duration=2.0), filename="x.mov") + + assert info.media_type is MediaType.QUICKTIME + assert (info.width, info.height) == (640, 480) + + +def test_mp4_honours_timescale(): + """ + Ensure MP4 duration is correctly calculated by dividing the tick count by the + timescale. + """ + info = probe_media( + mp4_with(640, 480, duration=5.0, timescale=600), filename="x.mp4" + ) + assert info.duration_seconds == pytest.approx(5.0, abs=0.01) + + +def test_heic_takes_the_largest_ispe(): + """ + Verify the HEIC parser selects the largest ispe box to avoid reporting a thumbnail. + """ + info = probe_media(heic_with(6016, 6016), filename="x.heic") + + assert (info.width, info.height) == (6016, 6016) + assert info.frames == 1 # a still, despite living in a video container + + +def test_webm_dimensions_and_duration(): + """Verify the probe correctly extracts dimensions and duration from a WebM file.""" + info = probe_media(webm_with(2560, 1440, duration=5.0), filename="x.webm") + + assert (info.width, info.height) == (2560, 1440) + assert info.duration_seconds == pytest.approx(5.0, abs=0.01) + assert info.frames == 5 + + +def test_unparseable_header_names_the_escape_hatch(): + """ + Ensure an unparseable header raises an error pointing the user to explicit + overrides. + """ + broken = b"\x89PNG\r\n\x1a\n" + b"\x00" * 40 # PNG magic, no IHDR + + with pytest.raises(GuardError) as exc_info: + probe_media(broken, filename="broken.png") + + message = str(exc_info.value) + assert "broken.png" in message + assert "width=" in message and "height=" in message + + +def test_truncated_header_raises_rather_than_guessing(): + """ + Verify a truncated media file raises an error rather than guessing the dimensions. + """ + with pytest.raises(GuardError, match="Could not read"): + probe_media(b"GIF89a", filename="tiny.gif") + + +def test_unsupported_media_type_is_rejected_first(): + """ + Ensure an unsupported media type is rejected before any header parsing is attempted. + """ + with pytest.raises(UnsupportedMediaTypeError): + probe_media(b"%PDF-1.4 nope", filename="doc.pdf") + + +def test_zero_dimensions_are_rejected(): + """ + Verify that a media header reporting dimensions of zero is treated as an error. + """ + with pytest.raises(GuardError, match="0x0 frame"): + probe_media(gif_with_size(0, 0), filename="empty.gif") diff --git a/tests/test_reactions.py b/tests/test_reactions.py new file mode 100644 index 0000000..96c3e00 --- /dev/null +++ b/tests/test_reactions.py @@ -0,0 +1,426 @@ +""" +Tests for the reactions resource: feedback on an activity result. +""" + +from __future__ import annotations + +import json +from uuid import uuid4 + +import httpx +import pytest +import respx + +from guard_client import ( + ActivityDetail, + ActivityResultItem, + DetectionResult, + Engine, + GuardClient, + GuardConflictError, + GuardError, + GuardNotFoundError, + GuardServerError, +) + +from .conftest import ( + ACTIVITY_ID, + API_KEY, + BASE_URL, + REACTION_ID, + SPACE_ID, + TASK_ID, + detail_response, + reaction_response, +) + +REACTIONS_URL = f"{BASE_URL}/api/v1/reactions/" + + +def body_of(route): + """Extract and parse the JSON payload from the last call to a mock route.""" + return json.loads(route.calls.last.request.read()) + + +def cloud_result(**overrides): + """Build a mock DetectionResult mimicking what analyze() returns from the cloud.""" + payload = { + "engine": Engine.CLOUD, + "activity_id": ACTIVITY_ID, + "results": [ + ActivityResultItem(task_id=TASK_ID, score=87, label="Deepfake"), + ], + } + payload.update(overrides) + return DetectionResult(**payload) + + +@respx.mock +def test_create_minimal(client): + """Verify that creating a reaction sends the expected minimal payload.""" + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response()) + ) + + reaction = client.reactions.create( + activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True + ) + + assert body_of(route) == { + "activity_id": str(ACTIVITY_ID), + "task_id": str(TASK_ID), + "is_positive": True, + } + assert reaction.id == REACTION_ID + assert reaction.is_positive is True + + +@respx.mock +def test_create_with_every_field(client): + """Verify that creating a reaction sends every optional field when provided.""" + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response( + 200, + json=reaction_response(is_positive=False, key_value=2, description="wrong"), + ) + ) + + reaction = client.reactions.create( + activity_id=ACTIVITY_ID, + task_id=TASK_ID, + is_positive=False, + key_value=2, + description="wrong", + ) + + assert body_of(route) == { + "activity_id": str(ACTIVITY_ID), + "task_id": str(TASK_ID), + "is_positive": False, + "key_value": 2, + "description": "wrong", + } + assert reaction.key_value == 2 + assert reaction.description == "wrong" + + +@respx.mock +def test_create_omits_unset_optionals(client): + """Ensure unset optional fields are omitted entirely rather than sent as null.""" + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response()) + ) + + client.reactions.create(activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True) + + body = body_of(route) + assert "key_value" not in body + assert "description" not in body + + +@respx.mock +def test_create_strips_description(client): + """ + Ensure the description string is stripped of leading and trailing whitespace before + sending. + """ + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response()) + ) + + client.reactions.create( + activity_id=ACTIVITY_ID, + task_id=TASK_ID, + is_positive=True, + description=" padded ", + ) + + assert body_of(route)["description"] == "padded" + + +@respx.mock +def test_create_treats_blank_description_as_unset(client): + """Ensure a description composed only of whitespace is dropped entirely.""" + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response()) + ) + + client.reactions.create( + activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True, description=" " + ) + + assert "description" not in body_of(route) + + +@respx.mock +def test_create_sends_no_guest_header(client): + """ + Ensure the client no longer sends a guest header, keeping requests authenticated. + """ + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response()) + ) + + client.reactions.create(activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True) + + assert "x-guest-id" not in route.calls.last.request.headers + + +def test_create_rejects_guest_id(client): + """Verify that passing the removed guest_id argument raises a type error.""" + with pytest.raises(TypeError): + client.reactions.create( + activity_id=ACTIVITY_ID, + task_id=TASK_ID, + is_positive=True, + guest_id=SPACE_ID, + ) + + +@respx.mock +def test_create_key_value_zero_is_sent(client): + """ + Ensure a key_value of 0 is properly sent and not accidentally dropped as falsy. + """ + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response(key_value=0)) + ) + + client.reactions.create( + activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=False, key_value=0 + ) + + assert body_of(route)["key_value"] == 0 + + +@pytest.mark.parametrize("bad", ["yes", 1, "true", None]) +@respx.mock +def test_create_rejects_non_bool_is_positive(client, bad): + """Ensure is_positive strictly requires a boolean value.""" + route = respx.post(REACTIONS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="Expected a boolean"): + client.reactions.create( + activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=bad + ) + + assert not route.called + + +@pytest.mark.parametrize("bad", ["2", 2.5, True, False, [2]]) +@respx.mock +def test_create_rejects_non_int_key_value(client, bad): + """Ensure key_value strictly requires an integer and rejects booleans.""" + route = respx.post(REACTIONS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="Invalid key_value"): + client.reactions.create( + activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True, key_value=bad + ) + + assert not route.called + + +@respx.mock +def test_create_rejects_overlong_description(client): + """ + Verify that descriptions exceeding the maximum allowed length are rejected locally. + """ + route = respx.post(REACTIONS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="at most 255 characters"): + client.reactions.create( + activity_id=ACTIVITY_ID, + task_id=TASK_ID, + is_positive=True, + description="d" * 256, + ) + + assert not route.called + + +@respx.mock +def test_description_at_the_limit_is_accepted(client): + """Ensure a description exactly at the maximum character limit is accepted.""" + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response()) + ) + + client.reactions.create( + activity_id=ACTIVITY_ID, + task_id=TASK_ID, + is_positive=True, + description="d" * 255, + ) + + assert len(body_of(route)["description"]) == 255 + + +@respx.mock +def test_create_for_extracts_ids(client): + """Verify create_for correctly extracts the necessary IDs from a DetectionResult.""" + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response()) + ) + result = cloud_result() + + client.reactions.create_for( + result, result.results[0], is_positive=False, key_value=1 + ) + + assert body_of(route) == { + "activity_id": str(ACTIVITY_ID), + "task_id": str(TASK_ID), + "is_positive": False, + "key_value": 1, + } + + +@respx.mock +def test_create_for_from_activity_detail(client): + """Verify create_for works correctly using an ActivityDetail object.""" + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response()) + ) + detail = ActivityDetail.model_validate(detail_response()) + + client.reactions.create_for( + detail, detail.result_payload.results[0], is_positive=True + ) + + body = body_of(route) + assert body["activity_id"] == str(ACTIVITY_ID) + assert body["task_id"] == str(TASK_ID) + + +@respx.mock +def test_create_for_rejects_foreign_item(client): + """ + Ensure create_for rejects an ActivityResultItem that does not belong to the result + object. + """ + route = respx.post(REACTIONS_URL).mock(return_value=httpx.Response(200)) + result = cloud_result() + stranger = ActivityResultItem(task_id=uuid4(), score=1, label="Other") + + with pytest.raises(GuardError, match="is not part of this activity's results"): + client.reactions.create_for(result, stranger, is_positive=True) + + assert not route.called + + +@respx.mock +def test_create_for_rejects_local_result(client): + """ + Ensure create_for raises an error for local engine results since they lack server + activity IDs. + """ + route = respx.post(REACTIONS_URL).mock(return_value=httpx.Response(200)) + local = DetectionResult( + engine=Engine.LOCAL, + activity_id=None, + results=[ActivityResultItem(task_id=TASK_ID, score=10, label="safe")], + ) + + with pytest.raises(GuardError, match="local engine"): + client.reactions.create_for(local, local.results[0], is_positive=True) + + assert not route.called + + +@pytest.mark.parametrize( + ("status", "expected", "detail"), + [ + (409, GuardConflictError, "Cannot react to an activity result multiple times"), + (404, GuardNotFoundError, "Activity not found"), + ], +) +@respx.mock +def test_create_maps_server_errors(client, status, expected, detail): + """Verify that specific server error statuses correctly map to custom exceptions.""" + respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(status, json={"detail": detail}) + ) + + with pytest.raises(expected) as exc_info: + client.reactions.create( + activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True + ) + + assert exc_info.value.status_code == status + assert detail in str(exc_info.value) + + +@respx.mock +def test_create_is_never_retried(monkeypatch): + """ + Ensure reaction creation is never retried to prevent triggering a false conflict + error. + """ + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(503, json={"detail": "unavailable"}) + ) + + retrying = GuardClient(api_key=API_KEY, base_url=BASE_URL, max_retries=3) + try: + with pytest.raises(GuardServerError): + retrying.reactions.create( + activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True + ) + finally: + retrying.close() + + assert route.call_count == 1 + + +@respx.mock +async def test_async_create(async_client): + """Verify the async client correctly creates a reaction.""" + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response()) + ) + + reaction = await async_client.reactions.create( + activity_id=ACTIVITY_ID, task_id=TASK_ID, is_positive=True + ) + + assert body_of(route)["is_positive"] is True + assert reaction.id == REACTION_ID + + +@respx.mock +async def test_async_create_for(async_client): + """Verify the async client can create a reaction using existing objects.""" + route = respx.post(REACTIONS_URL).mock( + return_value=httpx.Response(200, json=reaction_response()) + ) + result = cloud_result() + + await async_client.reactions.create_for( + result, result.results[0], is_positive=True, description="good" + ) + + body = body_of(route) + assert body["activity_id"] == str(ACTIVITY_ID) + assert body["description"] == "good" + + +@respx.mock +async def test_async_create_for_rejects_local_result(async_client): + """ + Ensure the async client raises an error for local results before hitting the + network. + """ + route = respx.post(REACTIONS_URL).mock(return_value=httpx.Response(200)) + local = DetectionResult( + engine=Engine.LOCAL, + results=[ActivityResultItem(task_id=TASK_ID, score=10, label="safe")], + ) + + with pytest.raises(GuardError, match="local engine"): + await async_client.reactions.create_for( + local, local.results[0], is_positive=True + ) + + assert not route.called diff --git a/tests/test_runners.py b/tests/test_runners.py new file mode 100644 index 0000000..8ecc3a2 --- /dev/null +++ b/tests/test_runners.py @@ -0,0 +1,536 @@ +""" +Tests for the runners resource: read, create and delete. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from guard_client import ( + GuardClient, + GuardConflictError, + GuardError, + GuardNotFoundError, + GuardPaymentRequiredError, + GuardServerError, + RunnerOrder, + RunnerStatus, +) + +from .conftest import ( + API_KEY, + BASE_URL, + ORG_ID, + PREDICTOR_ID, + RUNNER_ID, + SPACE_ID, + page_response, + runner_response, +) + +RUNNERS_URL = f"{BASE_URL}/api/v1/runners/" +OTHER_ORG = "99999999-9999-9999-9999-999999999999" + + +@pytest.fixture +def org_client(isolate_env): + """Provide a client preconfigured with a default organization ID.""" + with GuardClient( + api_key=API_KEY, organization_id=ORG_ID, base_url=BASE_URL, max_retries=0 + ) as c: + yield c + + +def params_of(route): + """Extract query parameters from the last request of a mock route.""" + return route.calls.last.request.url.params + + +def body_of(route): + """Extract and parse the JSON request body from the last call to a mock route.""" + return json.loads(route.calls.last.request.read()) + + +@respx.mock +def test_list_parses_runners(org_client): + """ + Verify that listing runners correctly parses their attributes from the response. + """ + respx.get(RUNNERS_URL).mock( + return_value=httpx.Response( + 200, json=page_response([runner_response()], count=2) + ) + ) + + page = org_client.runners.list() + + assert page.count == 2 + runner = page[0] + assert runner.id == RUNNER_ID + assert runner.status is RunnerStatus.RUNNING + assert runner.name == "runner-1" + assert runner.predictor_id == PREDICTOR_ID + assert runner.organization_name == "Test Org" + assert runner.terminated_at is None + + +@respx.mock +def test_list_parses_terminated_runner(org_client): + """Ensure that runners with a terminated status parse successfully.""" + respx.get(RUNNERS_URL).mock( + return_value=httpx.Response( + 200, json=page_response([runner_response(status="terminated")], count=1) + ) + ) + + assert org_client.runners.list()[0].status is RunnerStatus.TERMINATED + + +@respx.mock +def test_get_runner(org_client): + """Verify that fetching a single runner by ID returns the correct runner object.""" + route = respx.get(f"{RUNNERS_URL}{RUNNER_ID}").mock( + return_value=httpx.Response(200, json=runner_response()) + ) + + runner = org_client.runners.get(RUNNER_ID) + + assert route.called + assert runner.id == RUNNER_ID + + +@respx.mock +def test_list_passes_filters(org_client): + """ + Ensure that all provided runner list filters are correctly sent as query parameters. + """ + route = respx.get(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + org_client.runners.list( + predictor_id=PREDICTOR_ID, + statuses=["running", RunnerStatus.PENDING], + sort_by="name", + sort_order="desc", + skip=5, + limit=20, + ) + + params = params_of(route) + assert params["organization_id"] == str(ORG_ID) + assert params["predictor_id"] == str(PREDICTOR_ID) + assert params.get_list("statuses") == ["running", "pending"] + assert params["sort_by"] == "name" + assert params["sort_order"] == "desc" + assert params["skip"] == "5" + assert params["limit"] == "20" + + +@respx.mock +def test_list_omits_unset_filters(org_client): + """ + Verify that optional filters not explicitly provided are omitted from the request. + """ + route = respx.get(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + org_client.runners.list() + + params = params_of(route) + assert params["organization_id"] == str(ORG_ID) + for absent in ("predictor_id", "statuses", "sort_by", "sort_order"): + assert absent not in params + + +@respx.mock +def test_organization_id_comes_from_client_default(org_client): + """Ensure requests default to the organization ID configured on the client.""" + route = respx.get(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + org_client.runners.list() + + assert params_of(route)["organization_id"] == str(ORG_ID) + + +@respx.mock +def test_per_call_organization_id_overrides_default(org_client): + """Verify that an explicit per-call organization ID overrides the client default.""" + route = respx.get(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + org_client.runners.list(organization_id=OTHER_ORG) + + assert params_of(route)["organization_id"] == OTHER_ORG + + +@respx.mock +def test_missing_organization_id_raises_before_sending(client): + """Ensure an error is raised locally if no organization ID is available anywhere.""" + route = respx.get(RUNNERS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="GUARD_ORGANIZATION_ID"): + client.runners.list() + + assert not route.called + + +@respx.mock +def test_organization_id_from_dotenv(isolate_env): + """Verify that the organization ID is successfully loaded from a .env file.""" + from pathlib import Path + + Path(".env").write_text(f"GUARD_API_KEY=k\nGUARD_ORGANIZATION_ID={ORG_ID}\n") + route = respx.get(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + with GuardClient(base_url=BASE_URL) as c: + c.runners.list() + + assert params_of(route)["organization_id"] == str(ORG_ID) + + +@respx.mock +def test_terminated_is_not_filterable(org_client): + """ + Ensure filtering by 'terminated' is locally rejected since the API does not support + it. + """ + route = respx.get(RUNNERS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match=r"Invalid statuses='terminated'"): + org_client.runners.list(statuses=["terminated"]) + + assert not route.called + + +@respx.mock +def test_terminated_rejection_lists_the_filterable_values(org_client): + """ + Verify that rejecting an invalid filter status lists the valid filter options in the + message. + """ + respx.get(RUNNERS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError) as exc_info: + org_client.runners.list(statuses=[RunnerStatus.TERMINATED]) + + message = str(exc_info.value) + for allowed in ("pending", "running", "draining", "failed"): + assert allowed in message + assert "'terminated'," not in message # not offered as an option + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"statuses": ["bogus"]}, "Invalid statuses"), + ({"sort_by": "bogus"}, r"Invalid sort_by='bogus'.*'name', 'created_at'"), + ({"sort_order": "sideways"}, "Invalid sort_order"), + ({"skip": -1}, "Invalid skip=-1"), + ({"limit": 101}, "Invalid limit=101"), + ], +) +@respx.mock +def test_list_validates_before_sending(org_client, kwargs, message): + """ + Ensure that invalid pagination or sorting options raise an error locally before + network calls. + """ + route = respx.get(RUNNERS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match=message): + org_client.runners.list(**kwargs) + + assert not route.called + + +@respx.mock +def test_enum_member_accepted(org_client): + """Verify that sorting enum members can be passed directly as arguments.""" + route = respx.get(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + org_client.runners.list(sort_by=RunnerOrder.CREATED_AT) + + assert params_of(route)["sort_by"] == "created_at" + + +@respx.mock +def test_create_minimal(org_client): + """ + Verify that creating a runner with minimal arguments sends the expected payload. + """ + route = respx.post(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=runner_response(status="pending")) + ) + + runner = org_client.runners.create(predictor_id=PREDICTOR_ID) + + assert body_of(route) == { + "predictor_id": str(PREDICTOR_ID), + "organization_id": str(ORG_ID), + "is_default": False, + } + assert runner.status is RunnerStatus.PENDING + + +@respx.mock +def test_create_sends_is_default(org_client): + """Ensure the is_default flag is correctly included in the creation payload.""" + route = respx.post(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=runner_response()) + ) + + org_client.runners.create(predictor_id=PREDICTOR_ID, is_default=True) + + assert body_of(route)["is_default"] is True + + +@respx.mock +def test_create_dedupes_space_ids_preserving_order(org_client): + """Verify that dedicated space IDs are deduplicated while preserving their order.""" + route = respx.post(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=runner_response()) + ) + second = "66666666-6666-6666-6666-666666666666" + + org_client.runners.create( + predictor_id=PREDICTOR_ID, + organization_id=OTHER_ORG, + dedicated_space_ids=[SPACE_ID, second, SPACE_ID], + ) + + body = body_of(route) + assert body["dedicated_space_ids"] == [str(SPACE_ID), second] + assert body["organization_id"] == OTHER_ORG + + +@respx.mock +def test_create_omits_unset_space_ids(org_client): + """Ensure dedicated_space_ids is omitted from the body when not supplied.""" + route = respx.post(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=runner_response()) + ) + + org_client.runners.create(predictor_id=PREDICTOR_ID) + + assert "dedicated_space_ids" not in body_of(route) + + +@pytest.mark.parametrize("bad", ["yes", 1, "true"]) +@respx.mock +def test_create_rejects_non_bool_is_default(org_client, bad): + """Verify that passing a non-boolean value to is_default raises an error.""" + route = respx.post(RUNNERS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="Expected a boolean"): + org_client.runners.create(predictor_id=PREDICTOR_ID, is_default=bad) + + assert not route.called + + +@respx.mock +def test_create_without_organization_raises(client): + """Ensure an error is raised if creation is attempted without an organization ID.""" + route = respx.post(RUNNERS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="GUARD_ORGANIZATION_ID"): + client.runners.create(predictor_id=PREDICTOR_ID) + + assert not route.called + + +@pytest.mark.parametrize( + ("status", "expected", "detail"), + [ + (402, GuardPaymentRequiredError, "Organization needs active subscription"), + (402, GuardPaymentRequiredError, "Organization has reached limit of 3 runners"), + (409, GuardConflictError, "A runner with this name already exists"), + (404, GuardNotFoundError, "Predictor not found"), + ], +) +@respx.mock +def test_create_maps_server_errors(org_client, status, expected, detail): + """ + Verify that specific server error responses correctly map to custom exceptions. + """ + respx.post(RUNNERS_URL).mock( + return_value=httpx.Response(status, json={"detail": detail}) + ) + + with pytest.raises(expected) as exc_info: + org_client.runners.create(predictor_id=PREDICTOR_ID) + + assert exc_info.value.status_code == status + assert detail in str(exc_info.value) + + +@respx.mock +def test_delete_returns_none(org_client): + """Verify that successfully deleting a runner returns None.""" + route = respx.delete(f"{RUNNERS_URL}{RUNNER_ID}").mock( + return_value=httpx.Response( + 200, json={"message": "Runner deleted successfully"} + ) + ) + + assert org_client.runners.delete(RUNNER_ID) is None + assert route.called + + +@respx.mock +def test_delete_is_never_retried(monkeypatch): + """ + Ensure runner deletion requests are never retried to prevent triggering unintended + actions. + """ + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + route = respx.delete(f"{RUNNERS_URL}{RUNNER_ID}").mock( + return_value=httpx.Response(503, json={"detail": "unavailable"}) + ) + + retrying = GuardClient( + api_key=API_KEY, organization_id=ORG_ID, base_url=BASE_URL, max_retries=3 + ) + try: + with pytest.raises(GuardServerError): + retrying.runners.delete(RUNNER_ID) + finally: + retrying.close() + + assert route.call_count == 1 + + +@respx.mock +def test_delete_not_retried_on_connection_error(monkeypatch): + """Ensure deletion requests are not retried even on connection errors.""" + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + route = respx.delete(f"{RUNNERS_URL}{RUNNER_ID}").mock( + side_effect=httpx.ConnectError("dropped") + ) + + retrying = GuardClient( + api_key=API_KEY, organization_id=ORG_ID, base_url=BASE_URL, max_retries=3 + ) + try: + with pytest.raises(GuardError): + retrying.runners.delete(RUNNER_ID) + finally: + retrying.close() + + assert route.call_count == 1 + + +@respx.mock +def test_delete_maps_not_found(org_client): + """Verify that a 404 response on deletion maps to a GuardNotFoundError.""" + respx.delete(f"{RUNNERS_URL}{RUNNER_ID}").mock( + return_value=httpx.Response(404, json={"detail": "Runner not found"}) + ) + + with pytest.raises(GuardNotFoundError, match="Runner not found"): + org_client.runners.delete(RUNNER_ID) + + +@respx.mock +def test_iter_all_walks_pages(org_client): + """Ensure iter_all correctly handles pagination to yield all matching runners.""" + route = respx.get(RUNNERS_URL).mock( + side_effect=[ + httpx.Response( + 200, + json=page_response( + [runner_response(name=f"r{i}") for i in range(2)], 3 + ), + ), + httpx.Response(200, json=page_response([runner_response(name="r2")], 3)), + ] + ) + + names = [r.name for r in org_client.runners.iter_all(page_size=2)] + + assert names == ["r0", "r1", "r2"] + assert route.call_count == 2 + + +@pytest.fixture +async def async_org_client(isolate_env): + """Provide an async client preconfigured with a default organization ID.""" + from guard_client import AsyncGuardClient + + async with AsyncGuardClient( + api_key=API_KEY, organization_id=ORG_ID, base_url=BASE_URL, max_retries=0 + ) as c: + yield c + + +@respx.mock +async def test_async_list_and_get(async_org_client): + """Verify the async client can successfully list and retrieve runners.""" + respx.get(RUNNERS_URL).mock( + return_value=httpx.Response( + 200, json=page_response([runner_response()], count=1) + ) + ) + respx.get(f"{RUNNERS_URL}{RUNNER_ID}").mock( + return_value=httpx.Response(200, json=runner_response()) + ) + + page = await async_org_client.runners.list() + runner = await async_org_client.runners.get(RUNNER_ID) + + assert page[0].id == RUNNER_ID + assert runner.id == RUNNER_ID + + +@respx.mock +async def test_async_create_and_delete(async_org_client): + """Verify the async client can successfully create and delete runners.""" + create = respx.post(RUNNERS_URL).mock( + return_value=httpx.Response(200, json=runner_response(status="pending")) + ) + delete = respx.delete(f"{RUNNERS_URL}{RUNNER_ID}").mock( + return_value=httpx.Response(200, json={"message": "ok"}) + ) + + runner = await async_org_client.runners.create(predictor_id=PREDICTOR_ID) + result = await async_org_client.runners.delete(RUNNER_ID) + + assert body_of(create)["organization_id"] == str(ORG_ID) + assert runner.status is RunnerStatus.PENDING + assert result is None + assert delete.called + + +@respx.mock +async def test_async_iter_all(async_org_client): + """ + Ensure the async client can correctly paginate through all runners via iter_all. + """ + route = respx.get(RUNNERS_URL).mock( + side_effect=[ + httpx.Response( + 200, + json=page_response( + [runner_response(name=f"r{i}") for i in range(2)], 3 + ), + ), + httpx.Response(200, json=page_response([runner_response(name="r2")], 3)), + ] + ) + + names = [r.name async for r in async_org_client.runners.iter_all(page_size=2)] + + assert names == ["r0", "r1", "r2"] + assert route.call_count == 2 diff --git a/tests/test_shares.py b/tests/test_shares.py new file mode 100644 index 0000000..e661457 --- /dev/null +++ b/tests/test_shares.py @@ -0,0 +1,526 @@ +""" +Tests for the activity-shares resource: read and create. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +import httpx +import pytest +import respx + +from guard_client import ( + ActivityDetail, + ActivityResultItem, + DetectionResult, + Engine, + GuardAPIError, + GuardClient, + GuardConflictError, + GuardError, + GuardNotFoundError, + GuardServerError, + Share, + ShareOrder, +) + +from .conftest import ( + ACTIVITY_ID, + API_KEY, + BASE_URL, + ORG_ID, + SHARE_ID, + TASK_ID, + USER_ID, + detail_response, + page_response, + share_response, +) + +SHARES_URL = f"{BASE_URL}/api/v1/activities/shares/" + + +def params_of(route): + """Extract query parameters from the last request of a mock route.""" + return route.calls.last.request.url.params + + +def body_of(route): + """Extract and parse the JSON request body from the last call to a mock route.""" + return json.loads(route.calls.last.request.read()) + + +def cloud_result(**overrides): + """Build a mock DetectionResult representing a completed cloud analysis.""" + payload = { + "engine": Engine.CLOUD, + "activity_id": ACTIVITY_ID, + "results": [ActivityResultItem(task_id=TASK_ID, score=87, label="Deepfake")], + } + payload.update(overrides) + return DetectionResult(**payload) + + +@respx.mock +def test_list_parses_shares_and_nested_result(client): + """ + Verify that listing shares properly parses top-level attributes and nested result + objects. + """ + respx.get(SHARES_URL).mock( + return_value=httpx.Response( + 200, json=page_response([share_response()], count=3) + ) + ) + + page = client.shares.list() + + assert page.count == 3 + share = page[0] + assert share.id == SHARE_ID + assert share.share_url == "https://elhio.com/s/abc123" + assert share.task_name == "Deepfake" + assert share.expires_in == 7 + assert isinstance(share.result, ActivityResultItem) + assert share.result.score == 87 + assert share.result.label == "Deepfake" + + +@respx.mock +def test_list_url_has_trailing_slash(client): + """Ensure that list requests hit the endpoint URL with a trailing slash.""" + route = respx.get(SHARES_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.shares.list() + + assert route.calls.last.request.url.path == "/api/v1/activities/shares/" + + +@respx.mock +def test_get_share(client): + """Verify that fetching a single share by ID returns the expected share object.""" + route = respx.get(f"{SHARES_URL}{SHARE_ID}").mock( + return_value=httpx.Response(200, json=share_response()) + ) + + share = client.shares.get(SHARE_ID) + + assert route.called + assert route.calls.last.request.url.path == f"/api/v1/activities/shares/{SHARE_ID}" + assert share.id == SHARE_ID + + +@respx.mock +def test_list_passes_filters(client): + """ + Ensure that provided list filters are correctly serialized into request query + parameters. + """ + route = respx.get(SHARES_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.shares.list( + user_id=USER_ID, + statuses=["active", "expired"], + sort_by="created_at", + sort_order="asc", + skip=3, + limit=15, + ) + + params = params_of(route) + assert params["user_id"] == str(USER_ID) + assert params.get_list("statuses") == ["active", "expired"] + assert params["sort_by"] == "created_at" + assert params["sort_order"] == "asc" + assert params["skip"] == "3" + assert params["limit"] == "15" + + +@respx.mock +def test_list_omits_unset_filters(client): + """Verify that unset optional filters are excluded from query parameters.""" + route = respx.get(SHARES_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.shares.list() + + params = params_of(route) + for absent in ("user_id", "organization_id", "statuses", "sort_by", "sort_order"): + assert absent not in params + + +@respx.mock +def test_list_accepts_both_owner_filters(client): + """ + Ensure that specifying both user_id and organization_id filters simultaneously is + allowed. + """ + route = respx.get(SHARES_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.shares.list(user_id=USER_ID, organization_id=ORG_ID) + + params = params_of(route) + assert params["user_id"] == str(USER_ID) + assert params["organization_id"] == str(ORG_ID) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"statuses": ["bogus"]}, "Invalid statuses"), + ({"sort_by": "name"}, r"Invalid sort_by='name'.*'created_at'"), + ({"sort_order": "sideways"}, "Invalid sort_order"), + ({"limit": 0}, "Invalid limit=0"), + ], +) +@respx.mock +def test_list_validates_before_sending(client, kwargs, message): + """ + Ensure invalid list query arguments raise validation errors before sending network + calls. + """ + route = respx.get(SHARES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match=message): + client.shares.list(**kwargs) + + assert not route.called + + +@respx.mock +def test_enum_member_accepted(client): + """Verify that sort_by accepts ShareOrder enum members directly.""" + route = respx.get(SHARES_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.shares.list(sort_by=ShareOrder.CREATED_AT) + + assert params_of(route)["sort_by"] == "created_at" + + +def _share(expired_at: datetime) -> Share: + """Build a mock Share model instance with a specified expiration timestamp.""" + return Share.model_validate(share_response(expired_at=expired_at.isoformat())) + + +def test_is_expired_reflects_expired_at(): + """ + Verify that the is_expired property accurately calculates expiry relative to + current time. + """ + past = _share(datetime.now(timezone.utc) - timedelta(hours=1)) + future = _share(datetime.now(timezone.utc) + timedelta(hours=1)) + + assert past.is_expired is True + assert future.is_expired is False + + +def test_is_expired_treats_naive_timestamps_as_utc(): + """Ensure naive expiration datetime strings are assumed to be in UTC.""" + aware_past = datetime.now(timezone.utc) - timedelta(days=1) + naive_past = aware_past.replace( + tzinfo=None + ) # as a server without an offset sends it + share = Share.model_validate(share_response(expired_at=naive_past.isoformat())) + + assert share.is_expired is True + + +@respx.mock +def test_create_minimal(client): + """Verify that creating a share with minimal parameters sends expected JSON.""" + route = respx.post(SHARES_URL).mock( + return_value=httpx.Response(200, json=share_response()) + ) + + share = client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + + assert body_of(route) == { + "activity_id": str(ACTIVITY_ID), + "task_id": str(TASK_ID), + } + assert share.share_url == "https://elhio.com/s/abc123" + + +@respx.mock +def test_create_url_has_trailing_slash(client): + """Ensure that share creation requests include a trailing slash in the URL.""" + route = respx.post(SHARES_URL).mock( + return_value=httpx.Response(200, json=share_response()) + ) + + client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + + assert route.calls.last.request.url.path == "/api/v1/activities/shares/" + + +@respx.mock +def test_create_sends_expires_in(client): + """Verify that expires_in parameter is correctly sent when specified.""" + route = respx.post(SHARES_URL).mock( + return_value=httpx.Response(200, json=share_response(expires_in=3)) + ) + + share = client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID, expires_in=3) + + assert body_of(route)["expires_in"] == 3 + assert share.expires_in == 3 + + +@respx.mock +def test_create_omits_unset_expires_in(client): + """Ensure expires_in parameter is omitted from request body when not provided.""" + route = respx.post(SHARES_URL).mock( + return_value=httpx.Response(200, json=share_response()) + ) + + client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + + assert "expires_in" not in body_of(route) + + +@pytest.mark.parametrize("bad", [0, 8, -1, 100]) +@respx.mock +def test_create_rejects_out_of_range_expires_in(client, bad): + """ + Ensure expires_in values outside the 1-7 day range are rejected before network + requests. + """ + route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="Expected between 1 and 7 days"): + client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID, expires_in=bad) + + assert not route.called + + +@pytest.mark.parametrize("bad", [True, False, "3", 3.5]) +@respx.mock +def test_create_rejects_non_int_expires_in(client, bad): + """Ensure non-integer values for expires_in are rejected before network requests.""" + route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="Expected an integer number of days"): + client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID, expires_in=bad) + + assert not route.called + + +@respx.mock +def test_create_for_from_detection_result(client): + """ + Verify create_for correctly extracts activity and task IDs from a DetectionResult. + """ + route = respx.post(SHARES_URL).mock( + return_value=httpx.Response(200, json=share_response()) + ) + result = cloud_result() + + client.shares.create_for(result, result.results[0], expires_in=1) + + assert body_of(route) == { + "activity_id": str(ACTIVITY_ID), + "task_id": str(TASK_ID), + "expires_in": 1, + } + + +@respx.mock +def test_create_for_from_activity_detail(client): + """Verify create_for correctly extracts IDs from an ActivityDetail object.""" + route = respx.post(SHARES_URL).mock( + return_value=httpx.Response(200, json=share_response()) + ) + detail = ActivityDetail.model_validate(detail_response()) + + client.shares.create_for(detail, detail.result_payload.results[0]) + + assert body_of(route) == { + "activity_id": str(ACTIVITY_ID), + "task_id": str(TASK_ID), + } + + +@respx.mock +def test_create_for_rejects_foreign_item(client): + """ + Ensure create_for rejects an ActivityResultItem that does not belong to the source + activity. + """ + route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) + result = cloud_result() + stranger = ActivityResultItem(task_id=uuid4(), score=1, label="Other") + + with pytest.raises(GuardError, match="is not part of this activity's results"): + client.shares.create_for(result, stranger) + + assert not route.called + + +@respx.mock +def test_create_for_rejects_local_result(client): + """Ensure create_for raises an error when passed a local engine DetectionResult.""" + route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) + local = DetectionResult( + engine=Engine.LOCAL, + results=[ActivityResultItem(task_id=TASK_ID, score=10, label="safe")], + ) + + with pytest.raises(GuardError, match="local engine"): + client.shares.create_for(local, local.results[0]) + + assert not route.called + + +@respx.mock +def test_create_for_rejects_activity_without_results(client): + """ + Ensure create_for raises an error if the activity detail has no result payload. + """ + route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) + detail = ActivityDetail.model_validate( + detail_response(status="processing", result_payload=None) + ) + item = ActivityResultItem(task_id=TASK_ID, score=1, label="x") + + with pytest.raises(GuardError, match="available: none"): + client.shares.create_for(detail, item) + + assert not route.called + + +@pytest.mark.parametrize( + ("status", "expected", "detail"), + [ + (409, GuardConflictError, "Cannot create share for an activity multiple times"), + (404, GuardNotFoundError, "Activity not found"), + (404, GuardNotFoundError, "Associated media file no longer exists"), + (400, GuardAPIError, "Invalid task"), + (400, GuardAPIError, "No media key associated with this task"), + ], +) +@respx.mock +def test_create_maps_server_errors(client, status, expected, detail): + """ + Verify that API server error responses are properly mapped to corresponding client + exceptions. + """ + respx.post(SHARES_URL).mock( + return_value=httpx.Response(status, json={"detail": detail}) + ) + + with pytest.raises(expected) as exc_info: + client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + + assert exc_info.value.status_code == status + assert detail in str(exc_info.value) + + +@respx.mock +def test_create_is_never_retried(monkeypatch): + """ + Ensure share creation is not retried on server error to avoid duplicate conflicts. + """ + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + route = respx.post(SHARES_URL).mock( + return_value=httpx.Response(503, json={"detail": "unavailable"}) + ) + + retrying = GuardClient(api_key=API_KEY, base_url=BASE_URL, max_retries=3) + try: + with pytest.raises(GuardServerError): + retrying.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + finally: + retrying.close() + + assert route.call_count == 1 + + +@respx.mock +def test_iter_all_walks_pages(client): + """Ensure iter_all correctly fetches across multiple pages of shares.""" + route = respx.get(SHARES_URL).mock( + side_effect=[ + httpx.Response( + 200, + json=page_response( + [share_response(task_name=f"s{i}") for i in range(2)], 3 + ), + ), + httpx.Response( + 200, json=page_response([share_response(task_name="s2")], 3) + ), + ] + ) + + names = [s.task_name for s in client.shares.iter_all(page_size=2)] + + assert names == ["s0", "s1", "s2"] + assert route.call_count == 2 + + +@respx.mock +async def test_async_list_and_get(async_client): + """Verify that the async client properly handles listing and getting shares.""" + respx.get(SHARES_URL).mock( + return_value=httpx.Response( + 200, json=page_response([share_response()], count=1) + ) + ) + respx.get(f"{SHARES_URL}{SHARE_ID}").mock( + return_value=httpx.Response(200, json=share_response()) + ) + + page = await async_client.shares.list() + share = await async_client.shares.get(SHARE_ID) + + assert page[0].id == SHARE_ID + assert share.share_url == "https://elhio.com/s/abc123" + + +@respx.mock +async def test_async_create_and_create_for(async_client): + """ + Verify that the async client properly handles creating shares and using create_for. + """ + route = respx.post(SHARES_URL).mock( + return_value=httpx.Response(200, json=share_response()) + ) + + await async_client.shares.create(activity_id=ACTIVITY_ID, task_id=TASK_ID) + assert body_of(route)["activity_id"] == str(ACTIVITY_ID) + + detail = ActivityDetail.model_validate(detail_response()) + share = await async_client.shares.create_for( + detail, detail.result_payload.results[0], expires_in=2 + ) + + assert body_of(route)["expires_in"] == 2 + assert share.id == SHARE_ID + + +@respx.mock +async def test_async_create_for_rejects_foreign_item(async_client): + """ + Ensure async create_for rejects an ActivityResultItem from a different activity + before making a call. + """ + route = respx.post(SHARES_URL).mock(return_value=httpx.Response(200)) + result = cloud_result() + stranger = ActivityResultItem(task_id=uuid4(), score=1, label="Other") + + with pytest.raises(GuardError, match="is not part of this activity's results"): + await async_client.shares.create_for(result, stranger) + + assert not route.called diff --git a/tests/test_spaces.py b/tests/test_spaces.py new file mode 100644 index 0000000..f54d3f3 --- /dev/null +++ b/tests/test_spaces.py @@ -0,0 +1,792 @@ +""" +Tests for the spaces resource: filters, validation and pagination. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from guard_client import ( + GuardAPIError, + GuardAuthError, + GuardConflictError, + GuardError, + GuardNotFoundError, + GuardPaymentRequiredError, + GuardServerError, + MediaCategory, + SpaceOrder, + SpaceStatus, +) + +from .conftest import ( + API_KEY, + BASE_URL, + ORG_ID, + PREDICTOR_ID, + RUNNER_ID, + SPACE_ID, + TASK_ID, + USER_ID, + space_response, + spaces_page_response, +) + +SPACES_URL = f"{BASE_URL}/api/v1/spaces/" + + +def params_of(route): + """Extract query parameters from the last request of a mock route.""" + return route.calls.last.request.url.params + + +def body_of(route): + """Extract and parse the JSON request body from the last call to a mock route.""" + return json.loads(route.calls.last.request.read()) + + +@respx.mock +def test_create_organization_space(client): + """Verify that creating an organization space sends the expected payload.""" + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(200, json=space_response()) + ) + + space = client.spaces.create( + name="My Space", predictor_id=PREDICTOR_ID, organization_id=ORG_ID + ) + + assert body_of(route) == { + "name": "My Space", + "predictor_id": str(PREDICTOR_ID), + "is_public": False, + "organization_id": str(ORG_ID), + } + assert space.id == SPACE_ID + assert space.name == "Test Space" + + +@respx.mock +def test_create_user_space(client): + """Verify that creating a user space sends user_id and excludes organization_id.""" + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(200, json=space_response()) + ) + + client.spaces.create(name="Mine", predictor_id=PREDICTOR_ID, user_id=USER_ID) + + body = body_of(route) + assert body["user_id"] == str(USER_ID) + assert "organization_id" not in body + + +@respx.mock +def test_create_uses_client_organization_default(isolate_env): + """ + Ensure the client-level default organization ID is used when no owner is explicitly + named. + """ + from guard_client import GuardClient + + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(200, json=space_response()) + ) + + with GuardClient(api_key=API_KEY, organization_id=ORG_ID, base_url=BASE_URL) as c: + c.spaces.create(name="Defaulted", predictor_id=PREDICTOR_ID) + + assert body_of(route)["organization_id"] == str(ORG_ID) + + +@respx.mock +def test_explicit_user_id_beats_organization_default(isolate_env): + """Ensure an explicit user ID overrides the client-level default organization ID.""" + from guard_client import GuardClient + + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(200, json=space_response()) + ) + + with GuardClient(api_key=API_KEY, organization_id=ORG_ID, base_url=BASE_URL) as c: + c.spaces.create(name="Personal", predictor_id=PREDICTOR_ID, user_id=USER_ID) + + body = body_of(route) + assert body["user_id"] == str(USER_ID) + assert "organization_id" not in body + + +@respx.mock +def test_create_sends_all_supplied_fields(client): + """ + Verify that all provided optional fields are correctly included in the creation + body. + """ + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(200, json=space_response()) + ) + + client.spaces.create( + name="Full", + predictor_id=PREDICTOR_ID, + description="Everything set", + is_public=True, + organization_id=ORG_ID, + enabled_task_ids=[TASK_ID], + dedicated_runner_ids=[RUNNER_ID], + ) + + assert body_of(route) == { + "name": "Full", + "predictor_id": str(PREDICTOR_ID), + "is_public": True, + "description": "Everything set", + "organization_id": str(ORG_ID), + "enabled_task_ids": [str(TASK_ID)], + "dedicated_runner_ids": [str(RUNNER_ID)], + } + + +@respx.mock +def test_create_omits_unset_optionals(client): + """Ensure unset optional fields are omitted from the request body.""" + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(200, json=space_response()) + ) + + client.spaces.create( + name="Sparse", predictor_id=PREDICTOR_ID, organization_id=ORG_ID + ) + + body = body_of(route) + for absent in ( + "description", + "enabled_task_ids", + "dedicated_runner_ids", + "user_id", + ): + assert absent not in body + assert "is_default" not in body # never settable at creation + + +@respx.mock +def test_create_strips_name_and_description(client): + """ + Verify that whitespace is stripped from space names and descriptions before sending. + """ + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(200, json=space_response()) + ) + + client.spaces.create( + name=" Padded ", + predictor_id=PREDICTOR_ID, + description=" spaced ", + organization_id=ORG_ID, + ) + + body = body_of(route) + assert body["name"] == "Padded" + assert body["description"] == "spaced" + + +@respx.mock +def test_create_treats_blank_description_as_unset(client): + """ + Ensure descriptions containing only whitespace are omitted from the creation + payload. + """ + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(200, json=space_response()) + ) + + client.spaces.create( + name="Blank", + predictor_id=PREDICTOR_ID, + description=" ", + organization_id=ORG_ID, + ) + + assert "description" not in body_of(route) + + +@respx.mock +def test_create_dedupes_task_ids_preserving_order(client): + """ + Verify that duplicate task IDs are removed while preserving their original order. + """ + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(200, json=space_response()) + ) + second = "66666666-6666-6666-6666-666666666666" + + client.spaces.create( + name="Dupes", + predictor_id=PREDICTOR_ID, + organization_id=ORG_ID, + enabled_task_ids=[TASK_ID, second, TASK_ID], + ) + + assert body_of(route)["enabled_task_ids"] == [str(TASK_ID), second] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({}, "needs an owner"), + ({"user_id": USER_ID, "organization_id": ORG_ID}, "cannot belong to both"), + ({"organization_id": ORG_ID, "name": "ab"}, "at least 3 characters"), + ({"organization_id": ORG_ID, "name": " ab "}, "at least 3 characters"), + ({"organization_id": ORG_ID, "name": "x" * 51}, "at most 50 characters"), + ( + {"organization_id": ORG_ID, "description": "d" * 2001}, + "at most 2000 characters", + ), + ({"organization_id": ORG_ID, "is_public": "yes"}, "Expected a boolean"), + ( + {"user_id": USER_ID, "dedicated_runner_ids": [RUNNER_ID]}, + "only available for organization spaces", + ), + ], +) +@respx.mock +def test_create_validates_before_sending(client, kwargs, message): + """ + Ensure client-side parameter validation fails locally before issuing a create + request. + """ + route = respx.post(SPACES_URL).mock(return_value=httpx.Response(200)) + kwargs.setdefault("name", "Valid Name") + + with pytest.raises(GuardError, match=message): + client.spaces.create(predictor_id=PREDICTOR_ID, **kwargs) + + assert not route.called + + +@pytest.mark.parametrize( + ("status", "expected", "detail"), + [ + (409, GuardConflictError, "A space with this name already exists"), + (402, GuardPaymentRequiredError, "User needs active subscription"), + (403, GuardAuthError, "Predictor is not enabled in your active subscription"), + (404, GuardNotFoundError, "One or more tasks not found"), + (400, GuardAPIError, "Cannot create space without an owner"), + ], +) +@respx.mock +def test_create_maps_server_errors(client, status, expected, detail): + """ + Verify that server HTTP error responses during creation map to corresponding client + exceptions. + """ + respx.post(SPACES_URL).mock( + return_value=httpx.Response(status, json={"detail": detail}) + ) + + with pytest.raises(expected) as exc_info: + client.spaces.create( + name="Boom", predictor_id=PREDICTOR_ID, organization_id=ORG_ID + ) + + assert exc_info.value.status_code == status + assert detail in str(exc_info.value) + + +@respx.mock +def test_create_is_never_retried(monkeypatch): + """ + Ensure creation requests are not retried to prevent creating duplicate resources on + transient errors. + """ + from guard_client import GuardClient + + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(503, json={"detail": "unavailable"}) + ) + + retrying = GuardClient(api_key=API_KEY, base_url=BASE_URL, max_retries=3) + try: + with pytest.raises(GuardServerError): + retrying.spaces.create( + name="Once Only", predictor_id=PREDICTOR_ID, organization_id=ORG_ID + ) + finally: + retrying.close() + + assert route.call_count == 1 + + +@respx.mock +async def test_async_create(async_client): + """ + Verify that the async client creates a space and parses the response correctly. + """ + route = respx.post(SPACES_URL).mock( + return_value=httpx.Response(200, json=space_response()) + ) + + space = await async_client.spaces.create( + name="Async Space", predictor_id=PREDICTOR_ID, organization_id=ORG_ID + ) + + assert body_of(route)["name"] == "Async Space" + assert space.id == SPACE_ID + + +@respx.mock +async def test_async_create_validates_before_sending(async_client): + """ + Ensure async space creation performs client-side validation before sending network + requests. + """ + route = respx.post(SPACES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="needs an owner"): + await async_client.spaces.create(name="No Owner", predictor_id=PREDICTOR_ID) + + assert not route.called + + +@respx.mock +def test_list_parses_page(client): + """Verify that listing spaces correctly parses page metadata and space fields.""" + respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json=spaces_page_response(count=42)) + ) + + page = client.spaces.list() + + assert page.count == 42 + assert len(page) == 1 + space = page[0] + assert space.id == SPACE_ID + assert space.name == "Test Space" + assert space.status is SpaceStatus.ACTIVE + assert space.enabled_media == [MediaCategory.IMAGE, MediaCategory.VIDEO] + assert space.enabled_task_names == ["Deepfake", "Violence"] + + +@respx.mock +def test_page_behaves_like_a_list(client): + """ + Ensure the returned Page object supports sequence operations like iteration and + indexing. + """ + respx.get(SPACES_URL).mock( + return_value=httpx.Response( + 200, + json=spaces_page_response( + items=[space_response(name="A"), space_response(name="B")] + ), + ) + ) + + page = client.spaces.list() + + assert [s.name for s in page] == ["A", "B"] # iterable + assert page[1].name == "B" # indexable + assert len(page) == 2 # sized + assert bool(page) is True + assert page.data[0].name == "A" # underlying list still reachable + + +@respx.mock +def test_empty_page_is_falsy(client): + """ + Verify that an empty Page object evaluates as falsy and correctly reflects a zero + length. + """ + respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json={"data": [], "count": 0}) + ) + + page = client.spaces.list() + + assert not page + assert len(page) == 0 + assert page.has_more is False + + +@respx.mock +def test_has_more_reflects_count(client): + """ + Ensure has_more returns True when total count exceeds the number of items on the + current page. + """ + respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json=spaces_page_response(count=10)) + ) + + assert client.spaces.list().has_more is True + + +@respx.mock +def test_owner_name_prefers_organization(client): + """ + Verify that owner_name returns organization_name when an organization owns the + space. + """ + respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json=spaces_page_response()) + ) + + assert client.spaces.list()[0].owner_name == "Test Org" + + +@respx.mock +def test_get_parses_space_detail(client): + """ + Verify that space detail requests parse into SpaceDetail objects with full task + models. + """ + from .conftest import PREDICTOR_ID, space_detail_response + + route = respx.get(f"{SPACES_URL}{SPACE_ID}").mock( + return_value=httpx.Response(200, json=space_detail_response()) + ) + + detail = client.spaces.get(SPACE_ID) + + assert route.called + assert detail.id == SPACE_ID + assert detail.predictor_multiplier == 3 + assert detail.predictor_id == PREDICTOR_ID + assert detail.max_media_size == 52428800 + # enabled_tasks are full Task objects here, not the names the list endpoint returns. + assert [t.name for t in detail.enabled_tasks] == ["Deepfake"] + assert detail.enabled_tasks[0].reactions == {1: "Real photo", 2: "AI generated"} + assert detail.task_thresholds[0].blur_threshold == 50 + assert detail.owner_name == "Test Org" + + +@respx.mock +async def test_async_get_space_detail(async_client): + """Verify that the async client can fetch and parse detailed space information.""" + from .conftest import space_detail_response + + respx.get(f"{SPACES_URL}{SPACE_ID}").mock( + return_value=httpx.Response(200, json=space_detail_response()) + ) + + detail = await async_client.spaces.get(SPACE_ID) + + assert detail.predictor_multiplier == 3 + + +@respx.mock +def test_defaults_send_only_pagination(client): + """Ensure default space list calls send only skip and limit parameters.""" + route = respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json=spaces_page_response()) + ) + + client.spaces.list() + + params = params_of(route) + assert params["skip"] == "0" + assert params["limit"] == "100" + for absent in ( + "user_id", + "organization_id", + "predictor_id", + "is_public", + "sort_by", + ): + assert absent not in params + + +@respx.mock +def test_all_filters_reach_the_query_string(client): + """ + Verify that all supplied list filters are correctly appended to the request query + string. + """ + route = respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json=spaces_page_response()) + ) + + client.spaces.list( + organization_id=ORG_ID, + predictor_id=PREDICTOR_ID, + is_public=True, + is_default=False, + statuses=["active"], + sort_by="name", + sort_order="desc", + skip=10, + limit=25, + ) + + params = params_of(route) + assert params["organization_id"] == str(ORG_ID) + assert params["predictor_id"] == str(PREDICTOR_ID) + assert params["is_public"] == "true" + assert params["is_default"] == "false" + assert params.get_list("statuses") == ["active"] + assert params["sort_by"] == "name" + assert params["sort_order"] == "desc" + assert params["skip"] == "10" + assert params["limit"] == "25" + + +@pytest.mark.parametrize(("value", "expected"), [(True, "true"), (False, "false")]) +@respx.mock +def test_boolean_filters_serialise(client, value, expected): + """ + Verify that boolean filter parameters are formatted as 'true' or 'false' strings. + """ + route = respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json=spaces_page_response()) + ) + + client.spaces.list(is_public=value) + + assert params_of(route)["is_public"] == expected + + +@pytest.mark.parametrize( + "value", [1, 0, "true", "false", "yes", "no", "1", 1.0, "True"] +) +@respx.mock +def test_boolean_filters_reject_non_booleans(client, value): + """ + Ensure non-boolean truthy/falsy values are rejected locally for boolean filters. + """ + route = respx.get(SPACES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="Expected a boolean: True or False"): + client.spaces.list(is_public=value) + + assert not route.called + + +@respx.mock +def test_enum_filters_accept_strings_and_members(client): + """Verify that enum filters accept both raw strings and enum member instances.""" + route = respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json=spaces_page_response()) + ) + + client.spaces.list(sort_by=SpaceOrder.NAME, statuses=[SpaceStatus.ACTIVE]) + assert params_of(route)["sort_by"] == "name" + + client.spaces.list(sort_by="created_at") + assert params_of(route)["sort_by"] == "created_at" + + +@respx.mock +def test_conflicting_owner_filters_raise_before_any_request(client): + """ + Ensure specifying both user_id and organization_id filters raises an error locally + before calling the API. + """ + route = respx.get(SPACES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="both user_id and organization_id"): + client.spaces.list(user_id=SPACE_ID, organization_id=ORG_ID) + + assert not route.called + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"sort_by": "bogus"}, r"Invalid sort_by='bogus'.*'name', 'created_at'"), + ({"sort_order": "sideways"}, r"Invalid sort_order='sideways'.*'asc', 'desc'"), + ({"statuses": ["deleted"]}, r"Invalid statuses='deleted'.*'active'"), + ({"is_public": "maybe"}, r"Invalid is_public='maybe'.*Expected a boolean"), + ({"is_default": 7}, r"Invalid is_default=7.*Expected a boolean"), + ], +) +@respx.mock +def test_invalid_filter_values_raise(client, kwargs, message): + """ + Ensure invalid filter options trigger local validation failures before any network + request. + """ + route = respx.get(SPACES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match=message): + client.spaces.list(**kwargs) + + assert not route.called + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"skip": -1}, "Invalid skip=-1"), + ({"limit": 0}, "Invalid limit=0"), + ({"limit": 101}, "Invalid limit=101"), + ], +) +@respx.mock +def test_invalid_pagination_raises(client, kwargs, message): + """Ensure out-of-range pagination options trigger local validation failures.""" + route = respx.get(SPACES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match=message): + client.spaces.list(**kwargs) + + assert not route.called + + +@respx.mock +def test_iter_all_walks_pages(client): + """Verify that iter_all transparently paginates across multiple pages of spaces.""" + first = [space_response(name=f"s{i}") for i in range(3)] + second = [space_response(name="s3")] + route = respx.get(SPACES_URL).mock( + side_effect=[ + httpx.Response(200, json={"data": first, "count": 4}), + httpx.Response(200, json={"data": second, "count": 4}), + ] + ) + + names = [s.name for s in client.spaces.iter_all(page_size=3)] + + assert names == ["s0", "s1", "s2", "s3"] + assert route.call_count == 2 + assert route.calls[0].request.url.params["skip"] == "0" + assert route.calls[1].request.url.params["skip"] == "3" + + +@respx.mock +def test_iter_all_single_short_page_makes_one_request(client): + """ + Ensure iter_all makes only one request when the first page contains all available + items. + """ + route = respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json=spaces_page_response(count=1)) + ) + + assert len(list(client.spaces.iter_all())) == 1 + assert route.call_count == 1 + + +@respx.mock +def test_iter_all_stops_on_full_page_when_count_reached(client): + """ + Verify iter_all stops fetching once total retrieved items equal total count, even + on a full page. + """ + items = [space_response(name=f"s{i}") for i in range(2)] + route = respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json={"data": items, "count": 2}) + ) + + assert len(list(client.spaces.iter_all(page_size=2))) == 2 + assert route.call_count == 1 + + +@respx.mock +def test_iter_all_terminates_when_count_is_wrong(client): + """ + Ensure iter_all terminates when a returned page is short, preventing infinite loops + on inaccurate counts. + """ + items = [space_response(name="s0")] + route = respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json={"data": items, "count": 9999}) + ) + + assert len(list(client.spaces.iter_all(page_size=5))) == 1 + assert route.call_count == 1 + + +@respx.mock +def test_iter_all_passes_filters_to_every_page(client): + """ + Verify that iter_all includes specified filter parameters in every page request. + """ + pages = [space_response(name=f"s{i}") for i in range(2)] + route = respx.get(SPACES_URL).mock( + side_effect=[ + httpx.Response(200, json={"data": pages, "count": 3}), + httpx.Response(200, json={"data": [space_response(name="s2")], "count": 3}), + ] + ) + + list(client.spaces.iter_all(is_public=True, sort_by="name", page_size=2)) + + for call in route.calls: + assert call.request.url.params["is_public"] == "true" + assert call.request.url.params["sort_by"] == "name" + + +@respx.mock +def test_iter_all_is_lazy(client): + """ + Ensure breaking early out of iter_all halts pagination and avoids fetching remaining + pages. + """ + route = respx.get(SPACES_URL).mock( + return_value=httpx.Response( + 200, + json={ + "data": [space_response(name=f"s{i}") for i in range(2)], + "count": 100, + }, + ) + ) + + for space in client.spaces.iter_all(page_size=2): + if space.name == "s0": + break + + assert route.call_count == 1 + + +@respx.mock +async def test_async_list(async_client): + """Verify that the async client can fetch and parse a page of spaces.""" + respx.get(SPACES_URL).mock( + return_value=httpx.Response(200, json=spaces_page_response(count=5)) + ) + + page = await async_client.spaces.list(sort_by="name") + + assert page.count == 5 + assert page[0].id == SPACE_ID + + +@respx.mock +async def test_async_list_validates_filters(async_client): + """ + Ensure the async client performs local validation on filter parameters prior to + network requests. + """ + route = respx.get(SPACES_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match="Invalid sort_by"): + await async_client.spaces.list(sort_by="nope") + + assert not route.called + + +@respx.mock +async def test_async_iter_all(async_client): + """Ensure async iter_all transparently paginates across multiple pages of spaces.""" + route = respx.get(SPACES_URL).mock( + side_effect=[ + httpx.Response( + 200, + json={ + "data": [space_response(name=f"s{i}") for i in range(2)], + "count": 3, + }, + ), + httpx.Response(200, json={"data": [space_response(name="s2")], "count": 3}), + ] + ) + + names = [space.name async for space in async_client.spaces.iter_all(page_size=2)] + + assert names == ["s0", "s1", "s2"] + assert route.call_count == 2 diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..c1c89c7 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,183 @@ +""" +Tests for the tasks resource. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from guard_client import GuardError, TaskOrder, TaskStatus + +from .conftest import ( + BASE_URL, + ORG_ID, + PREDICTOR_ID, + TASK_ID, + page_response, + task_response, +) + +TASKS_URL = f"{BASE_URL}/api/v1/tasks/" + + +@respx.mock +def test_list_parses_tasks(client): + """ + Verify that listing tasks correctly parses all fields including reaction dictionary + key types. + """ + respx.get(TASKS_URL).mock( + return_value=httpx.Response(200, json=page_response([task_response()], count=4)) + ) + + page = client.tasks.list() + + assert page.count == 4 + task = page[0] + assert task.id == TASK_ID + assert task.name == "Deepfake" + assert task.status is TaskStatus.ACTIVE + assert task.description == "Detects synthetic media" + # keys arrive as JSON strings but are coerced to the ints the API means them as + assert task.reactions == {1: "Real photo", 2: "AI generated"} + assert all(isinstance(key, int) for key in task.reactions) + + +@respx.mock +def test_list_filters_by_predictor(client): + """ + Ensure that filtering tasks by predictor_id sends the expected query parameter. + """ + route = respx.get(TASKS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.tasks.list(predictor_id=PREDICTOR_ID, sort_by="name", limit=50) + + params = route.calls.last.request.url.params + assert params["predictor_id"] == str(PREDICTOR_ID) + assert params["sort_by"] == "name" + assert params["limit"] == "50" + + +@respx.mock +def test_list_omits_unset_filters(client): + """Verify that unset task filters are omitted from query parameters.""" + route = respx.get(TASKS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.tasks.list() + + params = route.calls.last.request.url.params + for absent in ( + "user_id", + "organization_id", + "predictor_id", + "sort_by", + "sort_order", + ): + assert absent not in params + + +@respx.mock +def test_list_accepts_both_owner_filters(client): + """ + Ensure that passing both user_id and organization_id filters is permitted for + tasks. + """ + route = respx.get(TASKS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.tasks.list(user_id=TASK_ID, organization_id=ORG_ID) + + assert route.called + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"sort_by": "bogus"}, r"Invalid sort_by='bogus'.*'name', 'created_at'"), + ({"sort_order": "sideways"}, "Invalid sort_order"), + ({"limit": 101}, "Invalid limit=101"), + ], +) +@respx.mock +def test_list_validates_before_sending(client, kwargs, message): + """ + Verify that invalid query arguments trigger client-side validation errors before + calling the API. + """ + route = respx.get(TASKS_URL).mock(return_value=httpx.Response(200)) + + with pytest.raises(GuardError, match=message): + client.tasks.list(**kwargs) + + assert not route.called + + +@respx.mock +def test_enum_member_accepted(client): + """ + Verify that TaskOrder enum members are accepted directly as sort_by parameters. + """ + route = respx.get(TASKS_URL).mock( + return_value=httpx.Response(200, json=page_response([])) + ) + + client.tasks.list(sort_by=TaskOrder.CREATED_AT) + + assert route.calls.last.request.url.params["sort_by"] == "created_at" + + +@respx.mock +def test_missing_optional_fields_tolerated(client): + """ + Verify that task responses missing description or reactions default to None or empty + dictionary. + """ + minimal = {"id": str(TASK_ID), "status": "active", "name": "Bare"} + respx.get(TASKS_URL).mock( + return_value=httpx.Response(200, json=page_response([minimal], count=1)) + ) + + task = client.tasks.list()[0] + + assert task.description is None + assert task.reactions == {} + + +@respx.mock +def test_iter_all_walks_pages(client): + """ + Ensure that iter_all transparently fetches across multiple pages of tasks. + """ + route = respx.get(TASKS_URL).mock( + side_effect=[ + httpx.Response( + 200, + json=page_response([task_response(name=f"t{i}") for i in range(2)], 3), + ), + httpx.Response(200, json=page_response([task_response(name="t2")], 3)), + ] + ) + + names = [t.name for t in client.tasks.iter_all(page_size=2)] + + assert names == ["t0", "t1", "t2"] + assert route.call_count == 2 + + +@respx.mock +async def test_async_list(async_client): + """Verify that the async client correctly lists and parses tasks.""" + respx.get(TASKS_URL).mock( + return_value=httpx.Response(200, json=page_response([task_response()], count=1)) + ) + + page = await async_client.tasks.list(predictor_id=PREDICTOR_ID) + + assert page[0].id == TASK_ID diff --git a/tests/test_tokens.py b/tests/test_tokens.py new file mode 100644 index 0000000..ed5f03c --- /dev/null +++ b/tests/test_tokens.py @@ -0,0 +1,172 @@ +""" +Tests for the token formula: frames x resolution cost x multiplier. +""" + +from __future__ import annotations + +import pytest + +from guard_client import ( + GuardError, + TokenEstimate, + estimate_tokens, + frames_for, + tier_for, +) + + +@pytest.mark.parametrize( + ("duration", "expected"), + [ + (0.0, 1), # a still image bills a single frame + (0.4, 1), + (1.0, 1), + (10.4, 11), # a partial second is still processed + (59.9, 60), + (60.0, 60), + ], +) +def test_frames_round_up(duration, expected): + """Verify that video durations round up to the nearest integer frame count.""" + assert frames_for(duration) == expected + + +def test_negative_duration_raises(): + """Ensure that passing a negative duration raises a GuardError.""" + with pytest.raises(GuardError, match="Expected 0 or greater"): + frames_for(-1.0) + + +@pytest.mark.parametrize( + ("width", "height", "tier", "cost"), + [ + (640, 480, 1, 1), + (1920, 1080, 1, 1), # top of tier 1 + (1921, 1080, 2, 2), # one pixel over + (2560, 1440, 2, 2), # top of tier 2 + (2561, 1440, 3, 4), + (3840, 2160, 3, 4), # top of tier 3, UHD + ], +) +def test_tier_boundaries(width, height, tier, cost): + """ + Verify that resolution boundaries accurately map to the expected tier and cost. + """ + assert tier_for(width, height) == (tier, cost) + + +@pytest.mark.parametrize( + ("width", "height"), + [ + (1920, 1080), + (2560, 1440), + (3840, 2160), + (2560, 1080), + ], +) +def test_tier_is_orientation_independent(width, height): + """ + Verify that resolution tier calculations yield the same result regardless of aspect + ratio orientation. + """ + assert tier_for(width, height) == tier_for(height, width) + + +def test_ultrawide_costs_by_long_side(): + """Verify that ultrawide resolutions are priced based on their longest dimension.""" + assert tier_for(2560, 1080) == (2, 2) + + +def test_dci_4k_raises(): + """Ensure that DCI 4K resolution raises an error while standard UHD is accepted.""" + assert tier_for(3840, 2160) == (3, 4) + + with pytest.raises(GuardError, match="exceeds the largest tier"): + tier_for(4096, 2160) + + +def test_above_top_tier_raises(): + """ + Ensure that resolutions exceeding the maximum long-side threshold raise a + GuardError. + """ + with pytest.raises(GuardError, match=r"long side \(3841\)"): + tier_for(3841, 100) + + +@pytest.mark.parametrize( + ("width", "height"), + [ + (0, 1080), + (1920, 0), + (-1, 1080), + (1920, -1), + (0, 0), + ], +) +def test_non_positive_dimensions_raise(width, height): + """Verify that zero or negative dimensions raise a GuardError.""" + with pytest.raises(GuardError, match="must be positive"): + tier_for(width, height) + + +def test_full_formula(): + """ + Verify that the token cost calculation produces the expected total and breakdown. + """ + est = estimate_tokens(frames=10, width=3840, height=2160, multiplier=3) + + assert est.tokens == 120 # 10 x 4 x 3 + assert est.frames == 10 + assert est.resolution_tier == 3 + assert est.tier_cost == 4 + assert est.multiplier == 3 + + +def test_single_image_at_tier_one(): + """Verify the calculation for a single tier-one image.""" + est = estimate_tokens(frames=1, width=800, height=600, multiplier=1) + assert est.tokens == 1 + + +def test_estimate_reports_the_breakdown(): + """ + Ensure TokenEstimate carries the complete parameter breakdown alongside the total. + """ + est = estimate_tokens( + frames=5, width=2560, height=1440, multiplier=2, duration_seconds=4.2 + ) + + assert isinstance(est, TokenEstimate) + assert (est.frames, est.tier_cost, est.multiplier) == (5, 2, 2) + assert est.tokens == est.frames * est.tier_cost * est.multiplier + assert est.width == 2560 + assert est.height == 1440 + assert est.duration_seconds == 4.2 + + +def test_str_is_readable(): + """ + Verify that string representation of TokenEstimate formats as human-readable + breakdown. + """ + est = estimate_tokens(frames=10, width=1920, height=1080, multiplier=2) + assert str(est) == "20 tokens (10 frames x 1 x 2)" + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"frames": 0}, "Invalid frames=0.*Expected 1 or greater"), + ({"frames": -3}, "Invalid frames=-3.*Expected 1 or greater"), + ({"multiplier": 0}, "Invalid multiplier=0.*Expected 1 or greater"), + ({"multiplier": -2}, "Invalid multiplier=-2.*Expected 1 or greater"), + ], +) +def test_out_of_range_inputs_raise(kwargs, message): + """Verify that non-positive frame or multiplier inputs raise a GuardError.""" + base = {"frames": 1, "width": 800, "height": 600, "multiplier": 1} + base.update(kwargs) + + with pytest.raises(GuardError, match=message): + estimate_tokens(**base) diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..70d287a --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,423 @@ +""" +Tests for auth, locale, error mapping and retry behaviour. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from guard_client import ( + GuardAPIError, + GuardAuthError, + GuardConflictError, + GuardConnectionError, + GuardNotFoundError, + GuardPaymentRequiredError, + GuardRateLimitError, + GuardServerError, + GuardValidationError, +) +from guard_client.transport import DEFAULT_BASE_URL, SyncTransport, TransportConfig + +from .conftest import API_KEY, BASE_URL + +PATH = "/api/v1/ping" +URL = f"{BASE_URL}{PATH}" + + +@pytest.fixture +def transport(): + """Provide a SyncTransport instance preconfigured for unit testing.""" + config = TransportConfig(api_key=API_KEY, base_url=BASE_URL, max_retries=0) + with SyncTransport(config) as t: + yield t + + +@respx.mock +def test_sends_bearer_token(transport): + """ + Verify that every request automatically carries the Authorization Bearer header. + """ + route = respx.get(URL).mock(return_value=httpx.Response(200, json={"ok": True})) + + assert transport.request("GET", PATH) == {"ok": True} + assert route.calls.last.request.headers["Authorization"] == f"Bearer {API_KEY}" + + +@respx.mock +def test_appends_lang_param(transport): + """ + Verify that requests append the default lang query parameter for localized + responses. + """ + route = respx.get(URL).mock(return_value=httpx.Response(200, json={})) + + transport.request("GET", PATH) + assert route.calls.last.request.url.params["lang"] == "en" + + +@respx.mock +def test_locale_is_configurable(): + """ + Verify that setting a custom locale correctly updates the lang query parameter. + """ + config = TransportConfig( + api_key=API_KEY, base_url=BASE_URL, locale="de", max_retries=0 + ) + route = respx.get(URL).mock(return_value=httpx.Response(200, json={})) + + with SyncTransport(config) as t: + t.request("GET", PATH) + + assert route.calls.last.request.url.params["lang"] == "de" + + +@respx.mock +def test_drops_none_params(transport): + """ + Verify that query parameters with None values are automatically stripped from the + request. + """ + route = respx.get(URL).mock(return_value=httpx.Response(200, json={})) + + transport.request("GET", PATH, params={"skip": 0, "space_id": None}) + + params = route.calls.last.request.url.params + assert "space_id" not in params + assert params["skip"] == "0" + + +@respx.mock +def test_returns_none_for_204(transport): + """Verify that receiving a 204 No Content response returns None.""" + respx.get(URL).mock(return_value=httpx.Response(204)) + assert transport.request("GET", PATH) is None + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + (400, GuardAPIError), + (401, GuardAuthError), + (402, GuardPaymentRequiredError), + (403, GuardAuthError), + (404, GuardNotFoundError), + (409, GuardConflictError), + (422, GuardValidationError), + (429, GuardRateLimitError), + (500, GuardServerError), + (503, GuardServerError), + ], +) +@respx.mock +def test_maps_status_to_exception(transport, status, expected): + """ + Verify that HTTP error status codes are correctly mapped to specific Guard + exceptions. + """ + respx.get(URL).mock(return_value=httpx.Response(status, json={"detail": "boom"})) + + with pytest.raises(expected) as exc_info: + transport.request("GET", PATH) + + assert exc_info.value.status_code == status + assert "boom" in str(exc_info.value) + + +@respx.mock +def test_prefers_detail_message(transport): + """Verify that error messages prioritize the detail field from the JSON response.""" + respx.get(URL).mock( + return_value=httpx.Response(400, json={"detail": "File exceeds the limit"}) + ) + + with pytest.raises(GuardAPIError, match="File exceeds the limit"): + transport.request("GET", PATH) + + +@respx.mock +def test_parses_422_detail_list(transport): + """ + Verify that 422 error details structured as lists are formatted into readable error + messages. + """ + respx.get(URL).mock( + return_value=httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "media_size"], + "msg": "field required", + "type": "missing", + } + ] + }, + ) + ) + + with pytest.raises(GuardValidationError) as exc_info: + transport.request("GET", PATH) + + assert "media_size: field required" in str(exc_info.value) + assert exc_info.value.errors[0]["type"] == "missing" + + +@respx.mock +def test_falls_back_on_non_json_body(transport): + """ + Verify that non-JSON error response bodies fall back gracefully to status line + messages. + """ + respx.get(URL).mock(return_value=httpx.Response(500, text="gateway")) + + with pytest.raises(GuardServerError, match="API Error: 500"): + transport.request("GET", PATH) + + +@respx.mock +def test_captures_request_id(transport): + """ + Verify that x-request-id headers on error responses are captured in the exception. + """ + respx.get(URL).mock( + return_value=httpx.Response( + 500, json={"detail": "x"}, headers={"x-request-id": "req-9"} + ) + ) + + with pytest.raises(GuardServerError) as exc_info: + transport.request("GET", PATH) + + assert exc_info.value.request_id == "req-9" + + +@respx.mock +def test_rate_limit_exposes_retry_after(transport): + """ + Verify that rate limit exceptions parse and expose the retry-after header value. + """ + respx.get(URL).mock( + return_value=httpx.Response( + 429, json={"detail": "slow down"}, headers={"retry-after": "7"} + ) + ) + + with pytest.raises(GuardRateLimitError) as exc_info: + transport.request("GET", PATH) + + assert exc_info.value.retry_after == 7.0 + + +@respx.mock +def test_wraps_connection_failure(transport): + """ + Verify that underlying HTTP connection errors are wrapped in GuardConnectionError. + """ + respx.get(URL).mock(side_effect=httpx.ConnectError("no route")) + + with pytest.raises(GuardConnectionError, match="no route"): + transport.request("GET", PATH) + + +@respx.mock +def test_retries_server_errors_then_succeeds(monkeypatch): + """ + Verify that transient server errors trigger retries up to the configured limit + before succeeding. + """ + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + config = TransportConfig(api_key=API_KEY, base_url=BASE_URL, max_retries=3) + route = respx.get(URL).mock( + side_effect=[ + httpx.Response(503, json={"detail": "unavailable"}), + httpx.Response(503, json={"detail": "unavailable"}), + httpx.Response(200, json={"ok": True}), + ] + ) + + with SyncTransport(config) as t: + assert t.request("GET", PATH) == {"ok": True} + + assert route.call_count == 3 + + +@respx.mock +def test_gives_up_after_max_retries(monkeypatch): + """Verify that retries halt and raise an exception once max_retries is reached.""" + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + config = TransportConfig(api_key=API_KEY, base_url=BASE_URL, max_retries=2) + route = respx.get(URL).mock( + return_value=httpx.Response(503, json={"detail": "down"}) + ) + + with SyncTransport(config) as t, pytest.raises(GuardServerError): + t.request("GET", PATH) + + assert route.call_count == 3 # initial attempt + 2 retries + + +@respx.mock +def test_does_not_retry_post_by_default(monkeypatch): + """ + Verify that POST requests are not retried by default to prevent non-idempotent + duplicate operations. + """ + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + config = TransportConfig(api_key=API_KEY, base_url=BASE_URL, max_retries=3) + route = respx.post(URL).mock( + return_value=httpx.Response(503, json={"detail": "down"}) + ) + + with SyncTransport(config) as t, pytest.raises(GuardServerError): + t.request("POST", PATH) + + assert route.call_count == 1 + + +@respx.mock +def test_retries_post_when_opted_in(monkeypatch): + """Verify that POST requests are retried when retry=True is explicitly passed.""" + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + config = TransportConfig(api_key=API_KEY, base_url=BASE_URL, max_retries=2) + route = respx.post(URL).mock( + side_effect=[ + httpx.Response(503, json={"detail": "down"}), + httpx.Response(200, json={"ok": True}), + ] + ) + + with SyncTransport(config) as t: + assert t.request("POST", PATH, retry=True) == {"ok": True} + + assert route.call_count == 2 + + +@respx.mock +def test_retry_false_suppresses_retries_on_idempotent_method(monkeypatch): + """ + Verify that passing retry=False disables retries even for naturally idempotent + HTTP methods. + """ + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + config = TransportConfig(api_key=API_KEY, base_url=BASE_URL, max_retries=3) + route = respx.delete(URL).mock( + return_value=httpx.Response(503, json={"detail": "down"}) + ) + + with SyncTransport(config) as t, pytest.raises(GuardServerError): + t.request("DELETE", PATH, retry=False) + + assert route.call_count == 1 + + +@respx.mock +def test_retry_none_keeps_method_default(monkeypatch): + """ + Verify that passing retry=None preserves default method-based idempotency retry + rules. + """ + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + config = TransportConfig(api_key=API_KEY, base_url=BASE_URL, max_retries=2) + route = respx.delete(URL).mock( + return_value=httpx.Response(503, json={"detail": "down"}) + ) + + with SyncTransport(config) as t, pytest.raises(GuardServerError): + t.request("DELETE", PATH, retry=None) + + assert route.call_count == 3 # DELETE is idempotent by default + + +@respx.mock +def test_retry_false_suppresses_connection_retries(monkeypatch): + """Verify that passing retry=False suppresses retries on connection failures.""" + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + config = TransportConfig(api_key=API_KEY, base_url=BASE_URL, max_retries=3) + route = respx.delete(URL).mock(side_effect=httpx.ConnectError("boom")) + + with SyncTransport(config) as t, pytest.raises(GuardConnectionError): + t.request("DELETE", PATH, retry=False) + + assert route.call_count == 1 + + +@respx.mock +def test_does_not_retry_client_errors(monkeypatch): + """Verify that 4xx client errors are never retried regardless of retry settings.""" + monkeypatch.setattr("guard_client.transport.time.sleep", lambda _: None) + config = TransportConfig(api_key=API_KEY, base_url=BASE_URL, max_retries=3) + route = respx.get(URL).mock( + return_value=httpx.Response(404, json={"detail": "nope"}) + ) + + with SyncTransport(config) as t, pytest.raises(GuardNotFoundError): + t.request("GET", PATH) + + assert route.call_count == 1 + + +def test_config_ignores_environment(monkeypatch): + """ + Verify that TransportConfig operates as a pure data container without reading + os.environ. + """ + monkeypatch.setenv("GUARD_API_KEY", "from-env") + monkeypatch.setenv("GUARD_BASE_URL", "http://localhost:8000") + + config = TransportConfig() + + assert config.api_key is None + assert config.base_url == DEFAULT_BASE_URL + + +def test_defaults_to_prod(): + """Verify that TransportConfig defaults to the production base URL.""" + config = TransportConfig(api_key="k") + assert config.base_url == DEFAULT_BASE_URL == "https://api.elhio.com" + + +def test_strips_trailing_slash(): + """ + Verify that TransportConfig strips trailing slashes from base_url during + initialization. + """ + assert TransportConfig(api_key="k", base_url="https://x.invalid/").base_url == ( + "https://x.invalid" + ) + + +@respx.mock +def test_upload_omits_auth_and_lang(transport): + """ + Verify that presigned storage uploads omit Authorization headers and lang query + parameters. + """ + route = respx.post("https://s3.test.invalid/bucket").mock( + return_value=httpx.Response(204) + ) + + transport.upload( + "https://s3.test.invalid/bucket", {"key": "uploads/a"}, "a.png", b"\x89PNG" + ) + + request = route.calls.last.request + assert "Authorization" not in request.headers + assert "lang" not in request.url.params + + +@respx.mock +def test_upload_raises_on_rejection(transport): + """Verify that non-2xx storage responses during uploads raise a GuardUploadError.""" + from guard_client import GuardUploadError + + respx.post("https://s3.test.invalid/bucket").mock( + return_value=httpx.Response(403, text="AccessDenied") + ) + + with pytest.raises(GuardUploadError) as exc_info: + transport.upload("https://s3.test.invalid/bucket", {}, "a.png", b"x") + + assert exc_info.value.status_code == 403 diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..49e64c6 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,17 @@ +""" +The version is written in two places; this is what keeps them equal. +""" + +from __future__ import annotations + +from importlib.metadata import version + +import guard_client + + +def test_dunder_version_matches_distribution_metadata(): + """ + Verify that guard_client.__version__ matches the installed package distribution + metadata version. + """ + assert guard_client.__version__ == version("guard-client") diff --git a/uv.lock b/uv.lock index 9f1b0ee..ade09e3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,62 +1,187 @@ version = 1 revision = 2 -requires-python = ">=3.8" +requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", ] [[package]] -name = "anyio" -version = "4.5.2" +name = "annotated-types" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, - { name = "idna", version = "3.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "sniffio", marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293, upload-time = "2024-10-13T22:18:03.307Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766, upload-time = "2024-10-13T22:18:01.524Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] -name = "anyio" -version = "4.12.1" +name = "ast-serialize" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, - { name = "idna", version = "3.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } + +[[package]] +name = "av" +version = "17.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/e3/477fa20578c284abeda08d91b63ee9abaebc93445d8feeb989d3d444bae1/av-17.1.0.tar.gz", hash = "sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3", size = 4288546, upload-time = "2026-06-07T05:52:55.999Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/c9d0cea4f6f8f93f5b15a39f99d2d593f922484f22a2d98a8d482283e15b/av-17.1.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:19c84fd72af5ef81a20f18fbc6f9aedff9e1455e53a7062c1d4c95926d73da4e", size = 22622703, upload-time = "2026-06-07T05:51:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/dc/57/74399770aa103ee4b5ff6da1781440c91a41901d89abb2433fe88773246e/av-17.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:19264c9bb4bee404accc7ce9ec461f2044b7f577a70234d29aafde31ed17de46", size = 18273538, upload-time = "2026-06-07T05:51:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/27c85b12e9ffa8f3f6854358b3eabcd91f3c29c7dac36843fa1376e833f4/av-17.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:22dff0ae582d10ef08c75c2150a4fd27cfc26653b54930c7c27b9f7b3aa20723", size = 34519101, upload-time = "2026-06-07T05:51:45.305Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/542d4bfd9f4aec5f3265985b9dbc6b259d45c2e668f9714e5f4e05b71e64/av-17.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:90c49bc9608377d01e82e747377505419a229464873341db18202d5dddecce5a", size = 36647600, upload-time = "2026-06-07T05:51:48.57Z" }, + { url = "https://files.pythonhosted.org/packages/63/1e/63bd5c59580f38109fa4c452b29b715a20c9a5eb3a078b3c447484593c40/av-17.1.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cc5a5247622cb77e24c342364eb68f88c1442ddfaab60c1f1f483359d3cc7879", size = 25786289, upload-time = "2026-06-07T05:51:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/70/30/78155cef0c9f8bc13f044130192c58bf962f2c9066982ff3593afe8d27f1/av-17.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff457ed419348e5b8e8c811d341389b052c5e4d5839da3794d019b125b9fe830", size = 35599848, upload-time = "2026-06-07T05:51:54.207Z" }, + { url = "https://files.pythonhosted.org/packages/76/cb/ae1d7a735a5ad9dc502dba864c51d605cbe932a769218352fd570254c38e/av-17.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1370b11a697eb3f2555906f8ab3519b0cfe48425d7830a3996ad42e6bffafda5", size = 26776479, upload-time = "2026-06-07T05:51:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/fb/40/128429b9eb0c4a2beb122ed8d04b189515df68967987c2654a2e262a5c43/av-17.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3dcd41e53f53f9a3260751d9c3c11d34e93d70d61e506c81f13dbc1e3606e07b", size = 37763744, upload-time = "2026-06-07T05:51:59.222Z" }, + { url = "https://files.pythonhosted.org/packages/01/6a/5980e7bbeeadfd7a9db8e38e9f1140a3e0c392fccc31bd7b1e4a75cf5a96/av-17.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:3453b06075c7bb973fdb6de52563f7692ff05cbc64c0bb45f4fd6e8709131f2f", size = 28126516, upload-time = "2026-06-07T05:52:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/ec/87/8036b5c781bc3639ea04ef42d4e26da253bd4bd4311d8705b6a1c8824047/av-17.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41", size = 22460847, upload-time = "2026-06-07T05:52:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/6d/af/dfdf6fc7b17814b50d0aa9e7a7e37b87be91be3890f44b0d525433cd1fd1/av-17.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9", size = 18159115, upload-time = "2026-06-07T05:52:06.64Z" }, + { url = "https://files.pythonhosted.org/packages/ad/13/64f6c466471cea225b8b2f4cdc51a571f8a286984b55a08d169b932fda5d/av-17.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847", size = 33224427, upload-time = "2026-06-07T05:52:09.165Z" }, + { url = "https://files.pythonhosted.org/packages/77/43/96b35170bf2e64e00a41748c6400ff73232dc0fc62ded283679fb07c7fe0/av-17.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f", size = 35370183, upload-time = "2026-06-07T05:52:11.959Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b3/8e8b4b6498731bfbd88e8399a756543f8088f1bd33d08eab678b5aebe728/av-17.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702", size = 24459265, upload-time = "2026-06-07T05:52:14.66Z" }, + { url = "https://files.pythonhosted.org/packages/14/ac/ceb84b7553db21f1143d817245c560d9267168e1e58b1a8eeae2b62c4d04/av-17.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f", size = 34283709, upload-time = "2026-06-07T05:52:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/4115fd84148c9a1cf365096694be6ac882fd3cd3cdb7a2f35e71fecf1631/av-17.1.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0", size = 25397573, upload-time = "2026-06-07T05:52:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/92e52d5ed0e0b84d9d93e52b4338c2713d8a44082b8696e6516fdae7c4e4/av-17.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e", size = 36451495, upload-time = "2026-06-07T05:52:22.581Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/53a7cd34adb6a971d7e6d99663e74db286966c9db8afdca17472fdf0f98e/av-17.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:5df5c1172ef1cf65a1529d612f7da7798ce2cf82c1ff7212466b538a6cc7214c", size = 28036393, upload-time = "2026-06-07T05:52:25.657Z" }, + { url = "https://files.pythonhosted.org/packages/66/47/cd9ae0edf2206351c1251bb94b5ec58728e42c5f6ee16c03c412f3a1bb3e/av-17.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:ee98534242a74da847af78624779ac5a3177dc7c69f956a4da9e6f0fdb37d7f6", size = 21174601, upload-time = "2026-06-07T05:52:28.077Z" }, + { url = "https://files.pythonhosted.org/packages/36/90/b5668cddb3c401fcf22553bc495d5b0c6d8a01d118624b26f0db1d0b8653/av-17.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5327807c1219293803ef0c5d1578ff3ae1cf638c09e5998962026e1a554ec240", size = 22699499, upload-time = "2026-06-07T05:52:30.335Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7e/7be6bfddb823d045ff9fd5d4deb922ee3847605e162c3882e6c45b4c35ff/av-17.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:6c9b71fe5c0c5a8d303b1588d4d8ce9397d6b023f467cfef95000ba1f75507fa", size = 18366696, upload-time = "2026-06-07T05:52:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/391dcfa75c1ae1977efca44b753a11b929399b558826670c16a8808dd0e3/av-17.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f997e3351bdf51127c07a74e21741a2996e9230cbeb2d81c14acde761b116c9c", size = 36582649, upload-time = "2026-06-07T05:52:35.218Z" }, + { url = "https://files.pythonhosted.org/packages/fb/32/7312854868b318b9d1b1dcbd1bddb460aaaeac7d57f816e11efec3bef5b1/av-17.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:efe9b1397300b67b644ad220c89df4892a76f2debe70f16bae1749fa20526e63", size = 38479390, upload-time = "2026-06-07T05:52:37.968Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/af47f59b4458e81ca7d89f477698dbfb3d5a0cd8ae6c1e4441d01074af8a/av-17.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:fa64e1f1500d01c4a98e7a41dc1a9a35fb4dfe71f5de0389264ec1192200c76a", size = 27127432, upload-time = "2026-06-07T05:52:40.371Z" }, + { url = "https://files.pythonhosted.org/packages/88/85/c2e6861baf0f8c7d21c4ce811d4d424fedac915e3910d3570ce4377717dc/av-17.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ffbd78d73d2c9bf31e9a007c992faec3991428b2941a3b085b84fb82e8c32d19", size = 37406592, upload-time = "2026-06-07T05:52:43.215Z" }, + { url = "https://files.pythonhosted.org/packages/ba/40/3cc13125aea976101c0858af99ac47257c0654411aa199b5d8e81eea7002/av-17.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bff8896454b38fcb785a70e5ae0485d7021cb776303a5849393128a30b8f850b", size = 28336228, upload-time = "2026-06-07T05:52:46.134Z" }, + { url = "https://files.pythonhosted.org/packages/a2/38/c7d9c3e746209a1a695c13e3aa7d817229e84a85d0a84271f313d1befdd3/av-17.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1284addf3c0dd939887a9722dc30df2241a97471ad52c3c507e31583ae22ff02", size = 39490680, upload-time = "2026-06-07T05:52:48.887Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/9d42da561b7b8f7dabdfaebba07b52977bee58c5c7e4285ac991abcfaa72/av-17.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ec630be6321b04e317862f6082e84812bbd801e55a3c2298312e3fc8a0a4af4f", size = 28355673, upload-time = "2026-06-07T05:52:51.614Z" }, + { url = "https://files.pythonhosted.org/packages/a8/41/562a61d5a61fba3ffb273a115e249f1d8471b9515c59fcc38b4b9deda238/av-17.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b41647e42884bf543b8e8d0a1dabd4d1b006c99183eb1a2d7afc5b01f73eeff4", size = 21324700, upload-time = "2026-06-07T05:52:53.972Z" }, ] [[package]] -name = "anyio" -version = "4.14.2" +name = "av" +version = "18.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba", size = 4340222, upload-time = "2026-07-02T06:37:58.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25", size = 22499354, upload-time = "2026-07-02T06:36:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484", size = 18175248, upload-time = "2026-07-02T06:37:01.741Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0", size = 33387843, upload-time = "2026-07-02T06:37:05.097Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017", size = 35536910, upload-time = "2026-07-02T06:37:08.806Z" }, + { url = "https://files.pythonhosted.org/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f", size = 38984619, upload-time = "2026-07-02T06:37:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1", size = 34451176, upload-time = "2026-07-02T06:37:15.154Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44", size = 36619869, upload-time = "2026-07-02T06:37:18.495Z" }, + { url = "https://files.pythonhosted.org/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629", size = 27556236, upload-time = "2026-07-02T06:37:21.388Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6", size = 20221133, upload-time = "2026-07-02T06:37:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5", size = 22740741, upload-time = "2026-07-02T06:37:26.659Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8", size = 18384189, upload-time = "2026-07-02T06:37:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e", size = 36749881, upload-time = "2026-07-02T06:37:33.096Z" }, + { url = "https://files.pythonhosted.org/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe", size = 38645927, upload-time = "2026-07-02T06:37:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b", size = 40454783, upload-time = "2026-07-02T06:37:40.904Z" }, + { url = "https://files.pythonhosted.org/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f", size = 37573117, upload-time = "2026-07-02T06:37:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b", size = 39669026, upload-time = "2026-07-02T06:37:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b", size = 28448336, upload-time = "2026-07-02T06:37:52.477Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c", size = 21377289, upload-time = "2026-07-02T06:37:55.935Z" }, ] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "c2pa-python" +version = "0.37.4" +source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "idna", version = "3.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "cryptography" }, + { name = "pytest" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "toml" }, + { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/6c/0e28502553bc32dc98f38a5415e20fa444e15e19ee7d17c8992cc8b23a28/c2pa_python-0.37.4.tar.gz", hash = "sha256:6aebfe6f669077d0662dcd9ad3612c40baf888ae22d2f4866d383a9f7dd46aee", size = 112919, upload-time = "2026-08-04T21:30:37.317Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/3b8041a3d163168489d1458e63c56cb7a782d36298520f8eac83b7da928c/c2pa_python-0.37.4-py3-none-macosx_10_9_universal2.whl", hash = "sha256:d2814c7d9c2759a7dec6404779aead36104fe807886ecf6c6cbd2af26c2ab3f0", size = 16183400, upload-time = "2026-08-04T21:30:13.39Z" }, + { url = "https://files.pythonhosted.org/packages/d2/22/199a897c0b526fec6c109c947db6c2010da70b782841e9a3f6c72af026de/c2pa_python-0.37.4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:839123d8854802848695ce24c7dd56661be56fbb815818de19f0d6a6d8d2bc16", size = 14148222, upload-time = "2026-08-04T21:30:16.092Z" }, + { url = "https://files.pythonhosted.org/packages/a7/79/7f9c670d644eb1681cf304fc22d5b31ea26cf9b9bece37a2ca4b1c5cd624/c2pa_python-0.37.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:243260fdd577549185175daaf9fdb4f30c8e940aaaf555b34c3579c6b4549c6c", size = 13782417, upload-time = "2026-08-04T21:30:18.885Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/4ba5294e9f32d97bb79ae8ea6ca0c9401c36e4e783fef3dcf1ebe6099830/c2pa_python-0.37.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:baa39c2befedcb1c45c0cb439a3b2b4f0c428ab3126e56c02b59a2e3700212f9", size = 13987587, upload-time = "2026-08-04T21:30:21.456Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/be5e4db496a4c52134970ffcd14bd557c7c25c6b9fa6f55166c08776a80f/c2pa_python-0.37.4-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8e2b1b214ea4e885178e1bd6c330d2c4c0c0d3aaa9cdb3bc28f7e7a7144ee5c0", size = 14636994, upload-time = "2026-08-04T21:30:24.021Z" }, + { url = "https://files.pythonhosted.org/packages/56/ef/575cfa3f60be714056fa1e6118375b92dbc252c3a63e0d37893a521f62e5/c2pa_python-0.37.4-py3-none-win_amd64.whl", hash = "sha256:4133e8e99059eeb8be0023fcb01aea17a588fe23911458813d6dc451d6eff712", size = 86337929, upload-time = "2026-08-04T21:30:28.796Z" }, + { url = "https://files.pythonhosted.org/packages/19/b6/f933ea182270f8285c66181a420a8fbcfa8a83ba590f265dd5dc864e3c26/c2pa_python-0.37.4-py3-none-win_arm64.whl", hash = "sha256:e183af9a6d511352cc427bed22611ea1b9dd1dd13620ec5555fb6c12643e6779", size = 84009543, upload-time = "2026-08-04T21:30:34.501Z" }, ] [[package]] @@ -68,29 +193,427 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "griffe" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffecli" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/44/63913c007814cab5ba9d36f25ad40dfc640c2e2931d195bd2d05f774a5d6/griffe-2.1.0.tar.gz", hash = "sha256:c58845df5a364feaabd05ee8c767b97b03e478da8aa18b9923553c812fb0d955", size = 244879, upload-time = "2026-06-19T12:05:41.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/fb/3c65d392feae6c36dc2b55a14dd8270b7b35a3171c93b71a4d2ee4abf241/griffe-2.1.0-py3-none-any.whl", hash = "sha256:2ccdab17fb9cd76f278d7b5611cfc8f68cbe846d8d48df63dff80b62ecfa6f65", size = 5140, upload-time = "2026-06-19T12:05:39.913Z" }, +] + +[[package]] +name = "griffecli" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/c6/90f85d47af96300d629b38c25b71aad9467a620cac964a39280e822efc8a/griffecli-2.1.0.tar.gz", hash = "sha256:2ff68dbee9395fdb668b10374c51683392d697b226ac60159798f4add1ee716c", size = 56913, upload-time = "2026-06-19T12:05:43.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/2f/513232ec1d5f5da182e4ce45a11427e37dd210844a9e2bca451fc9661fb3/griffecli-2.1.0-py3-none-any.whl", hash = "sha256:6e22b1423d562ddc510997b4be1fe89de59e19dcff78831c0f4bfc3b8134a718", size = 9500, upload-time = "2026-06-19T12:05:37.517Z" }, +] + +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "guard-client" version = "0.0.1" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "pydantic" }, + { name = "python-dotenv" }, +] + +[package.optional-dependencies] +local = [ + { name = "guard-local-detector" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "respx" }, + { name = "ruff" }, +] +docs = [ + { name = "griffe" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.metadata] -requires-dist = [{ name = "httpx", specifier = ">=0.28.1" }] +requires-dist = [ + { name = "guard-local-detector", marker = "extra == 'local'", specifier = ">=0.0.1" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "python-dotenv", specifier = ">=1.0" }, +] +provides-extras = ["local"] + +[package.metadata.requires-dev] +dev = [ + { name = "ipython", specifier = ">=8.0" }, + { name = "mypy", specifier = ">=1.11.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "respx", specifier = ">=0.21.1" }, + { name = "ruff", specifier = ">=0.6.0" }, +] +docs = [ + { name = "griffe", specifier = ">=2.0.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, +] + +[[package]] +name = "guard-local-detector" +version = "0.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av", version = "17.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "av", version = "18.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "c2pa-python" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "onnxruntime", version = "1.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "pillow-heif" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/38/daaef254940af0df2c2accbd105e42b5cf5b0589a70fc0b1033f1052376a/guard_local_detector-0.0.1.tar.gz", hash = "sha256:c0b9a84f6f3b76df743feef5db3b0df02aafe3cdf1edacd08168563467f592e6", size = 7384259, upload-time = "2026-08-07T06:36:32.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/fe/20308da79e835ba339f1ae2c92aef04d4eb9c26de17faca2b829d17182cd/guard_local_detector-0.0.1-py3-none-any.whl", hash = "sha256:d71c8031c79153c37ee211cc111b04cf379b277f29a0108d280e66627c87d905", size = 7273404, upload-time = "2026-08-07T06:36:30.143Z" }, +] [[package]] name = "h11" @@ -119,13 +642,10 @@ name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, - { name = "idna", version = "3.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "idna", version = "3.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ @@ -133,60 +653,1257 @@ wheels = [ ] [[package]] -name = "idna" -version = "3.15" +name = "humanfriendly" +version = "10.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", +dependencies = [ + { name = "pyreadline3", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, ] [[package]] name = "idna" version = "3.18" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] -name = "sniffio" -version = "1.3.1" +name = "iniconfig" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] -name = "typing-extensions" -version = "4.13.2" +name = "ipython" +version = "8.39.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.9'", + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, ] [[package]] -name = "typing-extensions" -version = "4.16.0" +name = "ipython" +version = "9.16.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/96/b150fe7e25a5a29ae9ac1374e71488639605d39a1ea4abb74c9ce33af235/ipython-9.16.1.tar.gz", hash = "sha256:5a3d1f9a47ff216d6cf9cf863124f6a2c1a198d1354c546a4d24a370a283b64c", size = 4515302, upload-time = "2026-08-03T08:36:15.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8e/1239df488393d61076653bfb29f759d0f60cab8e030abdf7c17c31539b51/ipython-9.16.1-py3-none-any.whl", hash = "sha256:4acae635506f6d352d94c4899a19d5f85f8bc4d230932342dca556fdab1c69b4", size = 625974, upload-time = "2026-08-03T08:36:13.654Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2f/ec5241c38e7fa0fe6c26bfc450e78b9489a6c3c08b394b85d2c10e506975/librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5", size = 148654, upload-time = "2026-07-08T12:24:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/d651e18d3ee7aa2879322368c4f278bb7ecaa6b90caadfdec4ebfa8389f3/librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547", size = 153537, upload-time = "2026-07-08T12:24:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/45/18/10bff2122577246009d9619b6569596daf69b7648812f997ca9ca0426f60/librt-0.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e56ea4ee4df77585a6b5c138f6538680886024fa559f5b55bd14b12e98e67b2", size = 494336, upload-time = "2026-07-08T12:24:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/87dfee871b852970f137fdeae8e2ca356c5ab38e6f21d2a3299535fc3159/librt-0.13.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f1f9cc4d09a46d9cb3c2063ae100629d3f52a6517c3c08c2f4c9828261883929", size = 485393, upload-time = "2026-07-08T12:24:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d5/625447a8c0441ff5f15f4ac5e1d323fb9d4d256ebfde7a3c8e003f646057/librt-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f125f5d46b20f89dc5587a55cc416b4ba2a5b2ffda36d048ee120e17598a653a", size = 515382, upload-time = "2026-07-08T12:24:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d8/1c8c49ea04235960426444deece9092a6b3a9587a850a81bae2335317411/librt-0.13.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2608d3b39f9e0b4a66a130d9150c615cba40a5090d25eeeaa225e0e46de8c0ac", size = 509483, upload-time = "2026-07-08T12:24:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/6f/65/f1760fc48050e215201a03506c32b7270159088d01f64557b53e39e74a45/librt-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fd35e95ab5e45c3901d37110263c7db85a961110f5460588fe37f8c131f88a7", size = 532503, upload-time = "2026-07-08T12:24:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/18/1b/793e281dcf494879eff99f642b63ebc9c7c58694a1c2d1e93362a22c7041/librt-0.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f31b0aa13c9b04370d4da6be1ab7779776b3a075cceb6747a39a4be85fe1e40", size = 537027, upload-time = "2026-07-08T12:24:39.34Z" }, + { url = "https://files.pythonhosted.org/packages/69/45/0801bbb40c9eea795d3dd3ce91c4c5f3fe7d42d23ec4be3e8cb283bcc754/librt-0.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0b795f5fc70fbbb787ceaf79bb3a0d627bcc33c53de51741755263ec406b775a", size = 517100, upload-time = "2026-07-08T12:24:40.907Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6c/eb5f514f8e29d4924bc0ff4601dd7b4175557e182e7c0721e84cffa39b8a/librt-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36b306a623aaad96fe4b378692b54f9c0789fccd833b9851753d5fbf6138cfde", size = 558653, upload-time = "2026-07-08T12:24:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/f140100d1b59fe87ff40b5ecbb4e27924335b189a784e230ee465452f6c2/librt-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a3762e75fcac8c9e4dacaaf438bffd9003e2ca2c531b756f3c0035deefa674c8", size = 104402, upload-time = "2026-07-08T12:24:43.668Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/57e40fef7cfb61869341cb28bdcefe8a950bebcbecca74a397bae14dce4a/librt-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:d63bae12a8aeb51380be3438e4dc4bd27354d0f8e19166b2f44e3e94d6f552dc", size = 125002, upload-time = "2026-07-08T12:24:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, + { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.23.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "coloredlogs", marker = "python_full_version < '3.11'" }, + { name = "flatbuffers", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "protobuf", marker = "python_full_version < '3.11'" }, + { name = "sympy", marker = "python_full_version < '3.11'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/d6/311b1afea060015b56c742f3531168c1644650767f27ef40062569960587/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3", size = 17195934, upload-time = "2025-10-27T23:06:14.143Z" }, + { url = "https://files.pythonhosted.org/packages/db/db/81bf3d7cecfbfed9092b6b4052e857a769d62ed90561b410014e0aae18db/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:b28740f4ecef1738ea8f807461dd541b8287d5650b5be33bca7b474e3cbd1f36", size = 19153079, upload-time = "2025-10-27T23:05:57.686Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4d/a382452b17cf70a2313153c520ea4c96ab670c996cb3a95cc5d5ac7bfdac/onnxruntime-1.23.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f7d1fe034090a1e371b7f3ca9d3ccae2fabae8c1d8844fb7371d1ea38e8e8d2", size = 15219883, upload-time = "2025-10-22T03:46:21.66Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/179bf90679984c85b417664c26aae4f427cba7514bd2d65c43b181b7b08b/onnxruntime-1.23.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ca88747e708e5c67337b0f65eed4b7d0dd70d22ac332038c9fc4635760018f7", size = 17370357, upload-time = "2025-10-22T03:46:57.968Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6d/738e50c47c2fd285b1e6c8083f15dac1a5f6199213378a5f14092497296d/onnxruntime-1.23.2-cp310-cp310-win_amd64.whl", hash = "sha256:0be6a37a45e6719db5120e9986fcd30ea205ac8103fd1fb74b6c33348327a0cc", size = 13467651, upload-time = "2025-10-27T23:06:11.904Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113, upload-time = "2025-10-22T03:47:33.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857, upload-time = "2025-10-22T03:46:37.578Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095, upload-time = "2025-10-22T03:46:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080, upload-time = "2025-10-22T03:47:00.265Z" }, + { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349, upload-time = "2025-10-22T03:47:25.783Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" }, + { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" }, + { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337, upload-time = "2025-10-22T03:46:35.168Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691, upload-time = "2025-10-22T03:46:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898, upload-time = "2025-10-22T03:46:30.039Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518, upload-time = "2025-10-22T03:47:05.407Z" }, + { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276, upload-time = "2025-10-22T03:47:31.193Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610, upload-time = "2025-10-22T03:46:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.28.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "flatbuffers", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "protobuf", marker = "python_full_version >= '3.11'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/4d/5014667e2a3a77d6e1b74cc3d88948d06163b8e0a33a84c85073322b5dec/onnxruntime-1.28.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f5c5daabd28aad610f83fdcf32acec8fb57e6adc6c6a39fe2a3c755db957b410", size = 19130506, upload-time = "2026-07-25T01:22:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/97/b7ce1bc8bb6048b5fe9129f55d6506dc19499068ef2e0a0af1ae3c8aa4e7/onnxruntime-1.28.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d66f9ceb29909c70839e4e4fb3435c7b490050d8f162bd5f3aba4ca01ee517f", size = 17039880, upload-time = "2026-07-25T01:21:37.538Z" }, + { url = "https://files.pythonhosted.org/packages/f3/17/4e5ecd8764f87573c495d834ce79e61ecca47f7a01d1e444a606e570edcb/onnxruntime-1.28.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a166b78ee04f3a37fa1ef82034b6a3ce96d9684e582d4d30b296de83e9998bb5", size = 19193162, upload-time = "2026-07-25T01:21:59.151Z" }, + { url = "https://files.pythonhosted.org/packages/9f/10/3d946d5d5f2cdcc3c8da36cae63190c516d16349edaffd944bda60ca4c3e/onnxruntime-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d650aeee29368414367b65529e90afe4bf1bab76254789063b8b2f7ea3013c8", size = 13752539, upload-time = "2026-07-25T01:22:24.524Z" }, + { url = "https://files.pythonhosted.org/packages/8f/74/1c440be7af1e026280b139caa1be5d11bd4dc368011ddbe8f5362b58e12f/onnxruntime-1.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:0faf85fb447a663c9cdadc39bd6b19bdf7bedded6699e45731b9b36c46fd993d", size = 13449940, upload-time = "2026-07-25T01:22:14.97Z" }, + { url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" }, + { url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" }, + { url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" }, + { url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" }, + { url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" }, + { url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "pillow-heif" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/6ab7a469ced899fbdbbc27b00067e4da02c21ff1ce7e9c6110f14e8fea6c/pillow_heif-1.5.0.tar.gz", hash = "sha256:16b11a37b762ff42da2d36527bb5cb14bd9194c24c389ee911155d6e23c53065", size = 17114105, upload-time = "2026-07-22T14:27:50.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/6a/2977e00f8130cad97b1841f70edf021d2feca5626e0f586c5ade29244d58/pillow_heif-1.5.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:f4675dcb6c9ada002f1180d1c7bfadcf98aa2865c6f5f4d2d3cc6f51d8126435", size = 4742817, upload-time = "2026-07-22T14:26:22.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/97a08343cda727b74dcf34755dc197f820e139fde239d860b379a9ba3cab/pillow_heif-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9650363fd4be654222f12bb487e914a21f898d00b844adb120606df210f4dc6c", size = 4262195, upload-time = "2026-07-22T14:26:24.875Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a1/543540c0d42e00c900607df57ff48a29b0a5e8d4cec0e7d8a42ca9e805e7/pillow_heif-1.5.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1793bb34067857a733439aa276195d112aee44a3854991b8c18a5597620ea80", size = 6346283, upload-time = "2026-07-22T14:26:26.775Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5b/eb32e2a55902350347f9a7a7895fa3a6cf6003198c3d8d5436d59711e840/pillow_heif-1.5.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb59104001afbf9263f261f2bea9d7481c3e99effd8b01e647a9138d562ddb48", size = 5601200, upload-time = "2026-07-22T14:26:28.262Z" }, + { url = "https://files.pythonhosted.org/packages/72/35/7e099dffe7c3345b7c9d0791aeed0fd24e741a3a73620373d3fb026f7694/pillow_heif-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c839fa42bc3a6115f863335313d69bb74882d79f4739e2535fedc48806ea39b", size = 7374295, upload-time = "2026-07-22T14:26:30.055Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/cada6dcd8b364780bdcb439ada9e66360233f4130e993c175402c6e47090/pillow_heif-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a23507bfcb230fe99a5ca5298e018985e2954a3e3ff2033a66dbb9b74fd93789", size = 6632790, upload-time = "2026-07-22T14:26:31.883Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d1/08c06ac30ff261fa7e582489f4f9a29c4ae13976f3f0637906017b8d6dad/pillow_heif-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:24e62ce775177bf6b039028c1b445893ec95a0b19771a458e36ff2f41c8ab44c", size = 6520153, upload-time = "2026-07-22T14:26:33.427Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/4f372c4163c6da00faedeb0ff885f35045e87b560cce012f993ab08e3f76/pillow_heif-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:5e01e4c3867852b4a3ae974580741986733d46b9626e7491868fc97f277804df", size = 3810991, upload-time = "2026-07-22T14:26:35.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/c9c74db32f93b890a9c90ee75e00e9a6e40f781417bc12acd1c1f0613c7a/pillow_heif-1.5.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:e07ad41fde21396c01b14f2f3026b26c85a4d8696e7b9514b6f43b29f9cc08fa", size = 4742816, upload-time = "2026-07-22T14:26:36.684Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/c3b63ff41ddd65bea40b0e1a176ae08c613e9948ca1a45afca10207dd19d/pillow_heif-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:88d74a74b144c2e6e99e18e23a1637aef5f63ad32c718c8358da87ea81f94194", size = 4262190, upload-time = "2026-07-22T14:26:38.242Z" }, + { url = "https://files.pythonhosted.org/packages/9a/19/aa5c298a1b9dc41cfc0a846cfc10f1aaac9b72b9eb27abfc954575bb8929/pillow_heif-1.5.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fece01cccd4faccadc912760cdae3e3a87b66fc2bda36e44d3bff79549c83d1c", size = 6348002, upload-time = "2026-07-22T14:26:39.653Z" }, + { url = "https://files.pythonhosted.org/packages/84/69/a8f1a432263632db6e633d87a1658265b09a1760020cb7dc711490b871c6/pillow_heif-1.5.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f7ada424aaf38d1a4592552c1340a04f1f0e3de30f6269514fbdc3f3b45bbb", size = 5602706, upload-time = "2026-07-22T14:26:41.241Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f5/9b2a6d237f40adb7c7300de8b5c74df6292a358b155e78510ea3362162d0/pillow_heif-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcdbac30cc1f10a89e6c6ce5ef92a539c34d03b1b68c536b092b8309c40cd02e", size = 7376030, upload-time = "2026-07-22T14:26:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/07/f4/bedec2253833a385ce4425c3791a5a37e493aab93a27622471928751201e/pillow_heif-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6838ed3b88bb1a96db3079ec4fb7850c17a6a743b506f12a26217d7bbefa2731", size = 6634293, upload-time = "2026-07-22T14:26:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/8d476b89c1e1da50a5f51eee44c2a18cab025e1dc12678ab0fe8ebadb1aa/pillow_heif-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3cb8c18e5939839cf76a1617f4df5e0b497d1e3a8260b273590168e72d7f8f8", size = 6520153, upload-time = "2026-07-22T14:26:46.743Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a9/813d6c8c6320bfbdde3f5ada81dc6db460a24589b38ba9dd33a62b7cab17/pillow_heif-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:392dc2eba0e7eb526b3a5f475c25ee162a341c695f8db321c7c106c8724303fa", size = 3810987, upload-time = "2026-07-22T14:26:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2c/6135e0f6d76d5c5e31dbeb8ea50a1c4f2f872122055bf5c2db714a772c1b/pillow_heif-1.5.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:115d538c6b4ebbf13dcbadfa5215ffe56365a0de10849ae3a75329d36bc04ee6", size = 4743136, upload-time = "2026-07-22T14:26:49.693Z" }, + { url = "https://files.pythonhosted.org/packages/23/3f/96c6143c006fa6188898c19cde2d131ca7f32f7f2d720514912d56cd29df/pillow_heif-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f14cde17b9288a9cbabc59c98a5928c54150ab98e3577ef2e4fd9f77f524b61", size = 4264318, upload-time = "2026-07-22T14:26:51.163Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/05177861cec40bf8645e5194d5bac8a88b409f4c63a447298df7ecc2ef67/pillow_heif-1.5.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a0e2b2966418cd2fb9a2b8427f60516980e3c9146ddf0a00e443f254d7ae291", size = 6346272, upload-time = "2026-07-22T14:26:52.746Z" }, + { url = "https://files.pythonhosted.org/packages/a8/70/5f9c81a022339914ea8b8370c284e3a4327d21dcf4242b9461baa44047dc/pillow_heif-1.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c8032c6c2bddde009e703e045a0e17f7724925403982821ddd1a8d78559f28d", size = 5601930, upload-time = "2026-07-22T14:26:54.412Z" }, + { url = "https://files.pythonhosted.org/packages/68/91/ce4fbd148e76daeb147a50ed761dc43d7f7d4b95c4796182b6f36e408afa/pillow_heif-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36742b4f3f5bee57a90d9492b17ad0336b95ab7fc358172cfae144505433ac94", size = 7374394, upload-time = "2026-07-22T14:26:56.061Z" }, + { url = "https://files.pythonhosted.org/packages/20/5c/32af6c25df15783a068b524648a83f854abddcd681155b6320737875c144/pillow_heif-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5533c061cfef1ed508137bb7303093ef7b3b1ea045c70a778554f6d551ed540", size = 6633463, upload-time = "2026-07-22T14:26:57.722Z" }, + { url = "https://files.pythonhosted.org/packages/eb/14/0a73f6d2605a20d32bbf7d20ee033d103b49215957d0330f09c64b3d0e19/pillow_heif-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:9aa2cdc94d8d0b26dd3c16c3421273eb394c24e708899d8740dc314904ea0453", size = 6520223, upload-time = "2026-07-22T14:26:59.31Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2c/102cfc58546ce2c00c312174ebc14d89efae8670f5c4f3c6bbe2e4f23197/pillow_heif-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:901121ed12b15bd1451e70ba7fca0233990ba958ec9c6f8751838402e69e1078", size = 3811009, upload-time = "2026-07-22T14:27:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/502ae6bebaf1fc48850f690858040932fb6fe0bd0de25b5e715df2517d14/pillow_heif-1.5.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:b736040d566271143c09bf87967549daf3d3090e9b4283ca2076c3383ab141d8", size = 4743134, upload-time = "2026-07-22T14:27:02.444Z" }, + { url = "https://files.pythonhosted.org/packages/e3/4f/a9f2e1525655c93e9b72cec3792a41f7d732222dad1d329fa04c227cbe28/pillow_heif-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d7c9dd6f490c433267ab9b66b63961e59a4c04c9d0e38ba66e4b6664d5e7d8", size = 4264310, upload-time = "2026-07-22T14:27:04.13Z" }, + { url = "https://files.pythonhosted.org/packages/7f/27/5f901b1d69d641b3b4e99aae6b3cbebe0cfa53188517ca7c79f86f86d4eb/pillow_heif-1.5.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdada8d4cd5886c9ec5454b907290c4cbd420809afbf2c251120f73ba2ee1d12", size = 6346291, upload-time = "2026-07-22T14:27:05.547Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7e/72c9fd99a74c82b08df3690077141bd16f1c9c7a3b3cd1c30e6fec4eb4a5/pillow_heif-1.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e87c9a51ee973b5c1896beba74ddf9b58417f5b8a52d021b493f3ef6257c5f90", size = 5601975, upload-time = "2026-07-22T14:27:07.252Z" }, + { url = "https://files.pythonhosted.org/packages/82/ff/be2b42f3aa4ec040fca5a2bab8bac6b611d39b7a3c69c185c85ae8537a6d/pillow_heif-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ff580e1453bcf3d4965dbbfa00bfabef7784a51214413a344ce2eb652a81a987", size = 7374392, upload-time = "2026-07-22T14:27:08.804Z" }, + { url = "https://files.pythonhosted.org/packages/b7/34/047aba6e860dbe8e32dc948d5ed707c921168769d34379b24956322fb4c3/pillow_heif-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2cfa3761cf4fe43ee4b909963f87f05bac925d7bcff7016802f02a26574fb11e", size = 6633487, upload-time = "2026-07-22T14:27:10.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/0e/4b463d7183ee45624fc822f22501feb4b772e5667377d2f981a31c08310a/pillow_heif-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:139f95d1052954804b349089deb91c55ecfe344a7a21ef46c961778a45a17589", size = 6520219, upload-time = "2026-07-22T14:27:12.642Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/e69cd5b9aea6e75e361d3d3a98201bb7eab31455b07a5b1fc8ec570ab2c1/pillow_heif-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:c6156bee6f69797a782d8ca373536dac4a7e0d4f793c9842f6bd09669fe4da0f", size = 3811013, upload-time = "2026-07-22T14:27:14.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/51/9b5a9352b86b7d82ad62969a84e3b4f998f6b5dc0320cec3e2d54d74d754/pillow_heif-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8d96783ca1648ba8384e66b888128ca3a48d769c0e3593acf84c40ce0c791629", size = 4743156, upload-time = "2026-07-22T14:27:15.721Z" }, + { url = "https://files.pythonhosted.org/packages/2a/10/dc89bec0edf37df5d7609d76ce125a2f8d1e901b2d34944d64d05f497bef/pillow_heif-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5c50626e77510f38cb11aacb3b4bba56eff17ce39a9d85b7ee210bd81387a083", size = 4264368, upload-time = "2026-07-22T14:27:17.198Z" }, + { url = "https://files.pythonhosted.org/packages/cf/54/837e9077784df4a0ba69d3e76e9f29a4e52929c84f0e1b0d29a13437a5cc/pillow_heif-1.5.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fb15135733626ee3fd4d7104bde1e54d4370f9f8b697a5d239b8b552b73fc7b", size = 6346432, upload-time = "2026-07-22T14:27:18.55Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/42f392c0513bf351ba2bca1350c457b0d7bd94f12d0e5fa74d7a9deae336/pillow_heif-1.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:646ddad98c8f9c5e637dbb84b6b7db905e565994e9fd6a496a36adff36ae28b3", size = 5602088, upload-time = "2026-07-22T14:27:20.112Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4d/8070974a2e695f83fb10883b7d83c3eda635561f99993623bf111a55a984/pillow_heif-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b9803c1291c7c598134f16016a071ec8d9dce268ab1806c8541450c4e3972893", size = 7374543, upload-time = "2026-07-22T14:27:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/4a/78/9a5abfb163b0fb44a4f624146de142ba4887e5011f965442af2c997db6bc/pillow_heif-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1ad925299422d4592e4b5344c03e25b27dc7a5130447603a89404d7d138b746c", size = 6633558, upload-time = "2026-07-22T14:27:23.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bc/7bae1c1efcec25dfdca950fe1a10cd723c544269db73203019e965394015/pillow_heif-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:cfd5f8a8a7b65f2ded217c5bde4dd9365b6b67187657a121b05224841a1b92c0", size = 6703549, upload-time = "2026-07-22T14:27:25.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/8d/fff074683f96eb004d7159228d2fc9e2e9fe8afd899d53aa695c622487d8/pillow_heif-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:6759febf63ea31fd00756e4a828cd643d0b9f98c93f5814b0ce5baa036da50e4", size = 4017234, upload-time = "2026-07-22T14:27:27.443Z" }, + { url = "https://files.pythonhosted.org/packages/57/04/fa5727a62b2fc37b611c7a172fc10c6719530d27bc9126de8456fac69346/pillow_heif-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e2c3f47eac33d8368ea5779eb7bd8c736e7048b5897474d7e1799c073c9505e3", size = 4744030, upload-time = "2026-07-22T14:27:29.047Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6f/afaccf55774c1084e5beba83f0ac201d315096f6a95d003696376184a7a3/pillow_heif-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d0881c82752d676fbe054cb0258100790ead94946fbb075ffa041bb9ae579b11", size = 4265061, upload-time = "2026-07-22T14:27:30.703Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a6/c90f9fffd5cb56e213753f22a9a955525d5ad99816481ae2392ab6c7ccd8/pillow_heif-1.5.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d5810606f70d3432b55987a5e44aa26fb7598fea565d8645b017f7ed935f5e99", size = 6351671, upload-time = "2026-07-22T14:27:32.231Z" }, + { url = "https://files.pythonhosted.org/packages/74/b3/e15933817e71b9fcd8865e3b64171995ed26f5ca329bf4d03f3571213c52/pillow_heif-1.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e737d326410e733868566c045f651b5371086a4ec294c40d39e129481d024bef", size = 5606028, upload-time = "2026-07-22T14:27:34.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/2665f6c781b90bb5a25595c58ede1955125f0ad2c7d81bf3c153c9e9c9d8/pillow_heif-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:522719e5dd56eae6f9f48828c08eefe0e5c02dd92f5a07780e6f3c04bdbcf880", size = 7379667, upload-time = "2026-07-22T14:27:35.883Z" }, + { url = "https://files.pythonhosted.org/packages/54/77/0f0487b9f6cb1f5dff629b42ad08a7ae1997886048c4c2671e17079ad5d6/pillow_heif-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f1d281b2efee954ef78c0a473a41498d0548a32290d7250b4879ddd2480dffb8", size = 6637581, upload-time = "2026-07-22T14:27:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b7/c65adbcab1b1c99c886c55b97b144b3092c432e7b0fedb91501c479cebe4/pillow_heif-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:65b3d619cea2d1f758cd039cddb331cb44d63768c355606ea9100cfee4421eb4", size = 6704174, upload-time = "2026-07-22T14:27:39.32Z" }, + { url = "https://files.pythonhosted.org/packages/17/53/2a52f64f399717adae560068e9b0ae12ae3e43be2c64991e246af5aedc3f/pillow_heif-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:508fc9dd8fb4df933b666c30b60b0930270d9e072fcd90b75990166050e44656", size = 4017728, upload-time = "2026-07-22T14:27:41.102Z" }, + { url = "https://files.pythonhosted.org/packages/f5/67/d710e1ee10ddbe748e9df5aef1df591123468616a40fada56569de0f2518/pillow_heif-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:563dd2cf45325944fb8d405f14c2876dc5e18e85a94dfafcd474579400010651", size = 4731076, upload-time = "2026-07-22T14:27:42.582Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/4259389bff87df8ea5d51f4399016e75171325dc2c21d5fd8ffd4bb9cebe/pillow_heif-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e402f7a6f3c3d8352e773562d35525b13ba1e9372ff660034754dd65c52ce463", size = 4261485, upload-time = "2026-07-22T14:27:44.235Z" }, + { url = "https://files.pythonhosted.org/packages/d6/38/e00160ffb328b639f2182b308c94a3f55123a115127a82a6aea25d51eb03/pillow_heif-1.5.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f8289a54b6109268ed3a518970eecf9c834d76ee2af5aa489051a0ed4d3d462", size = 6303812, upload-time = "2026-07-22T14:27:45.68Z" }, + { url = "https://files.pythonhosted.org/packages/12/1f/a64b73c8c8eac669e04d4769d6ed76507a80be1c20634adbd5579dbdee00/pillow_heif-1.5.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:920ea2d3a88dc115d58bd792ef3e89be98f01d68ccd144efcbffc5d4a683aba7", size = 5555237, upload-time = "2026-07-22T14:27:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/db/f6/4d85fc3a81cbb2969f485fe3dd165677574080a25f7160245cd4cae73998/pillow_heif-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6aa53c40d9aa1d7433b35823a8cd13463e057b2106919fec40cea14967548bd1", size = 6520559, upload-time = "2026-07-22T14:27:48.904Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, ] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "traitlets" +version = "5.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/2e/a7fbfe268c8a3b32546930c0297c101d65a4a14c304ad5790a9f478f0e4e/traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1", size = 166137, upload-time = "2026-08-03T08:32:36.848Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + +[[package]] +name = "wheel" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, +] From 88693b6014631739356c7f01ef442101dfea56f2 Mon Sep 17 00:00:00 2001 From: Stefan Scholz Date: Tue, 11 Aug 2026 12:56:52 +0200 Subject: [PATCH 2/2] add helping scripts and minor changes after production tests --- .env.example | 6 +- CONTRIBUTING.md | 31 +++++-- README.md | 55 ++++-------- pyproject.toml | 2 +- scripts/create_activity.py | 151 +++++++++++++++++++++++++++++++++ scripts/create_space.py | 119 ++++++++++++++++++++++++++ scripts/list_predictors.py | 98 +++++++++++++++++++++ scripts/list_spaces.py | 115 +++++++++++++++++++++++++ scripts/list_tasks.py | 96 +++++++++++++++++++++ src/guard_client/activities.py | 20 +++++ src/guard_client/display.py | 5 ++ src/guard_client/env.py | 9 +- src/guard_client/local.py | 6 +- src/guard_client/predictors.py | 21 ++++- src/guard_client/tasks.py | 20 ++++- tests/conftest.py | 3 +- tests/test_predictors.py | 10 ++- tests/test_tasks.py | 11 +-- 18 files changed, 700 insertions(+), 78 deletions(-) create mode 100644 scripts/create_activity.py create mode 100644 scripts/create_space.py create mode 100644 scripts/list_predictors.py create mode 100644 scripts/list_spaces.py create mode 100644 scripts/list_tasks.py diff --git a/.env.example b/.env.example index 5323143..fb79190 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,6 @@ GUARD_SPACE_ID= # (OPTIONAL) Read a different env file instead of `.env` #GUARD_ENV_FILE=.env.staging -# (OPTIONAL) Default media file for the smoke test -# Used only by scripts/smoke.py -#GUARD_MEDIA=tests/fixtures/sample.jpg +# (OPTIONAL) Default media file to analyze +# Used only by scripts/create_activity.py +#GUARD_MEDIA=path/to/photo.jpg diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76209cc..aa03dd2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,20 +124,26 @@ silently when the extra is not installed. Because of this, a green full-suite ru local engine. If you explicitly name the file, the test runner will loudly warn you if `guard_local` is missing. When you are finished, running `uv sync` returns you to the default environment. -### Smoke Testing against a Live API +### Testing against a Live API -The test suite is fully mocked and never touches the network. To exercise the real lifecycle end-to-end, use the smoke -script. **It runs against production by default and spends real tokens**. Each run creates two activities. You can point -it elsewhere using the `GUARD_BASE_URL` environment variable. +The test suite is fully mocked and never touches the network. To exercise the real lifecycle end-to-end, create an +activity. **This runs against production by default and spends real tokens** — one activity per run. You can point it +elsewhere using the `GUARD_BASE_URL` environment variable. ```bash -export GUARD_API_KEY=... # a token_raw from POST /api/v1/tokens/ +export GUARD_API_KEY=... # a token_raw from POST /api/v1/tokens/ export GUARD_SPACE_ID=... -uv run python scripts/smoke.py path/to/photo.jpg +# see the space and the projected cost, creating nothing +uv run python scripts/create_activity.py path/to/photo.jpg --list-only + +uv run python scripts/create_activity.py path/to/photo.jpg # against a local dev server instead -GUARD_BASE_URL=http://localhost:8000 uv run python scripts/smoke.py path/to/photo.jpg +GUARD_BASE_URL=http://localhost:8000 uv run python scripts/create_activity.py path/to/photo.jpg + +# on-device, which needs no space, no network and no tokens +uv run python scripts/create_activity.py path/to/photo.jpg --engine local ``` Credentials resolve the same way everywhere in this client: explicit arguments, followed by `GUARD_* `environment @@ -145,14 +151,21 @@ variables, and finally a `.env` file. You can simply run `cp .env.example .env` exporting variables. The `.env` file is git-ignored and must stay that way. Please never put a real key in `.env.example`. -If you do not have a `space_id` yet, two more scripts can help: +If you do not have a `space_id` yet, four more scripts can help: ```bash # list the spaces your key can see, with their ids uv run python scripts/list_spaces.py +# the two ids a new space needs: a predictor, and optionally some tasks +uv run python scripts/list_predictors.py +uv run python scripts/list_tasks.py --predictor-id + # walk predictors -> tasks -> create a space (--list-only creates nothing) uv run python scripts/create_space.py --list-only + +# and then, with a space id in hand, analyze something in it +uv run python scripts/create_activity.py path/to/photo.jpg --list-only ``` ## What to Watch Out For @@ -172,7 +185,7 @@ this package. the contract has changed. It is excluded from `ruff format` and carries its own per-file-ignores entry to preserve this. Please do not reformat it, and if you modify it, be sure to update both copies. -**Everything raised must subclass `GuardError`.** hat is the promise our `except` clauses rely on. The engine's own +**Everything raised must subclass `GuardError`.** That is the promise our `except` clauses rely on. The engine's own exceptions do not subclass it, so `local.py` translates each one before it escapes using `_map_local_error`. Anything that does not come from `guard_local` propagates untouched. This is intentional, as a bug in the engine should surface exactly as the bug it is. diff --git a/README.md b/README.md index 8efaad2..4c9d12b 100644 --- a/README.md +++ b/README.md @@ -121,43 +121,24 @@ not into a different data shape. ## Development -This project uses [uv](https://docs.astral.sh/uv/) for lightning-fast Python package and environment management. - -### Prerequisites - -* [uv](https://docs.astral.sh/uv/) (already installed on your system) - -### Setup - -1. Clone the repository: - ```bash - git clone https://github.com/elhio/guard-python.git - cd guard-python - ``` - -2. Sync the environment: - ```bash - uv sync - ``` - *This command automatically creates a `.venv` virtual environment, reads the `uv.lock` file, and installs all core* - *and development dependencies exactly as they were locked.* - -3. Run tests: - ```bash - uv run pytest - ``` - -4. Formatting, linting and type checking: - ```bash - uv run ruff format - uv run ruff check - uv run mypy src/ - ``` - -5. Build for production: - ```bash - uv build - ``` +This project uses [uv](https://docs.astral.sh/uv/) for package and environment management. A single `uv sync` creates +the `.venv`, reads `uv.lock`, and installs everything exactly as it was locked. The test suite is fully mocked, so +there is no API key to obtain and no network access at any point. + +```bash +# set up the environment +uv sync + +# run tests +uv run pytest + +# build for production +uv build +``` + +The [Contributing Guide](https://github.com/elhio/guard-python/blob/main/CONTRIBUTING.md) covers the rest: linting and +type checking, the documentation build, working against the optional local engine and its shared contract suite, and +testing end-to-end against a live API. ## Contributing diff --git a/pyproject.toml b/pyproject.toml index e086cb5..313f93e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ local = ["guard-local-detector>=0.0.1"] [project.urls] -Homepage = "https://github.com/elhio/guard-python" +Homepage = "https://elhio.com/" Repository = "https://github.com/elhio/guard-python" Issues = "https://github.com/elhio/guard-python/issues" Changelog = "https://github.com/elhio/guard-python/releases" diff --git a/scripts/create_activity.py b/scripts/create_activity.py new file mode 100644 index 0000000..e064b62 --- /dev/null +++ b/scripts/create_activity.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +Create one activity after showing the space and its estimated cost. + +This script runs the complete detection lifecycle through `analyze()`. It creates the +activity, uploads the media, confirms it, polls until completion, and prints the final +scores. Be aware that running this against a live API will spend real tokens. You can +pass `--list-only` to see the cost estimate without creating anything, or use +`--engine local` to run the detection on your device for free. + +Only `GUARD_API_KEY` and a space ID are required. Everything else resolves through the +standard client precedence using arguments, the environment, or a `.env` file. The +media file is taken from the first argument or the `GUARD_MEDIA` environment variable. + +Example: + ```bash + # preview the cost without creating anything + uv run python scripts/create_activity.py photo.jpg --list-only + + # run the activity against the cloud API + uv run python scripts/create_activity.py photo.jpg + uv run python scripts/create_activity.py photo.jpg --space-id + + # run on-device instead, which requires no space and consumes no tokens + uv run python scripts/create_activity.py photo.jpg --engine local + ``` +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import Optional + +from guard_client import GuardClient, GuardError, probe_media, read_env_file + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("media", nargs="?", help="media file; defaults to GUARD_MEDIA") + parser.add_argument("--space-id", help="the space to run in; or GUARD_SPACE_ID") + parser.add_argument("--user-id", help="create a user-owned activity") + parser.add_argument("--account-id", help="create a service-account-owned activity") + parser.add_argument( + "--engine", choices=["cloud", "local"], help="client default: cloud" + ) + parser.add_argument( + "--timeout", type=float, help="seconds to wait for processing to finish" + ) + parser.add_argument( + "--list-only", + action="store_true", + help="print the space and the estimate, then exit without creating anything", + ) + return parser.parse_args() + + +def from_env(name: str) -> Optional[str]: + """Read one setting the way the client does: real environment first, then .env.""" + return os.environ.get(name) or read_env_file().get(name) + + +def media_path(argument: Optional[str]) -> Path: + """The one setting the client does not resolve for us.""" + media = argument or from_env("GUARD_MEDIA") + if not media: + sys.exit("Pass a media file path as the first argument or set GUARD_MEDIA") + + path = Path(media) + if not path.is_file(): + sys.exit(f"No such file: {path}") + return path + + +def print_media(path: Path) -> None: + """Show what the probe read out of the file headers.""" + info = probe_media(path) + print(f"Media: {path.name}") + print(f" type {info.media_type.value}") + print(f" size {path.stat().st_size} bytes") + print(f" pixels {info.width}x{info.height}") + print(f" frames {info.frames} ({info.duration_seconds:.1f}s)") + + +def print_space(client: GuardClient, space_id: str, media: Path) -> None: + """Show the space this will run in, and what it is expected to cost.""" + space = client.spaces.get(space_id) + tasks = ", ".join(task.name for task in space.enabled_tasks) or "-" + + print(f"\nSpace: {space.name}") + print(f" id {space.id}") + print(f" predictor {space.predictor_name} (x{space.predictor_multiplier})") + print(f" max media {space.max_media_size or '-'} bytes") + print(f" tasks {tasks}") + + print(f"\nEstimated cost: {client.estimate_tokens(media, space_id=space_id)}") + print(" An estimate: the API reserves the minimum, payed_tokens is final.") + + +def main() -> int: + args = parse_args() + media = media_path(args.media) + local = args.engine == "local" + + space_id = args.space_id or from_env("GUARD_SPACE_ID") + if not local and not space_id: + sys.exit( + "Pass --space-id or set GUARD_SPACE_ID (scripts/list_spaces.py finds one)" + ) + + try: + with GuardClient(space_id=space_id, engine=args.engine) as client: + print(f"Connected to {client.base_url}\n") + + print_media(media) + if local: + print("\nEngine: local. No space, no network, no tokens.") + else: + print_space(client, str(space_id), media) + + if args.list_only: + print("\n--list-only: nothing was created.") + return 0 + + print(f"\nAnalyzing {media.name}...") + result = client.analyze( + media, + user_id=args.user_id, + account_id=args.account_id, + **({} if args.timeout is None else {"timeout": args.timeout}), + ) + + print(f"\nDone: engine={result.engine.value}") + print(f" activity {result.activity_id or '-'}") + for item in result.results: + print(f" - {item.label}: {item.score}/100") + if not result.results: + print(" (no results returned)") + print(f" max_score {result.max_score}") + except GuardError as exc: + # Covers a missing API key or space id too, which the client reports itself. + print(f"\nFAILED: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/create_space.py b/scripts/create_space.py new file mode 100644 index 0000000..fd68f1e --- /dev/null +++ b/scripts/create_space.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Create a space after showing the available predictors and tasks. + +A space needs a predictor and optionally a set of tasks. This script walks through the +entire flow. It lists the predictors, lists the tasks for the chosen predictor, creates +the space, and prints the new space ID. + +Only GUARD_API_KEY is required. Everything else resolves through the standard client +precedence using arguments, the environment, or a `.env` file. + +Example: + ```bash + # look around first without creating anything + uv run python scripts/create_space.py --list-only + + # create a personal space + uv run python scripts/create_space.py --name "Mine" --user-id --public + + # create a space for an organization + uv run python scripts/create_space.py --name "My Space" --organization-id + ``` +""" + +from __future__ import annotations + +import argparse +import sys + +from guard_client import GuardClient, GuardError + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--name", help="space name, 3-50 characters") + parser.add_argument("--description", help="optional, up to 2000 characters") + parser.add_argument( + "--predictor-id", help="defaults to the first available predictor" + ) + + owner = parser.add_mutually_exclusive_group() + owner.add_argument("--user-id", help="create a personal space") + owner.add_argument("--organization-id", help="create an organization space") + + parser.add_argument("--public", action="store_true", help="make the space public") + parser.add_argument( + "--all-tasks", + action="store_true", + help="enable every task the predictor supports (default: none)", + ) + parser.add_argument( + "--list-only", + action="store_true", + help="print predictors and tasks, then exit without creating anything", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + try: + with GuardClient() as client: + print(f"Connected to {client.base_url}\n") + + predictors = client.predictors.list() + if not predictors: + print("No predictors available to this key.", file=sys.stderr) + return 1 + + print("Predictors:") + for predictor in predictors: + media = "/".join(m.value for m in predictor.supported_media) or "-" + print(f" {predictor.id} {predictor.name:28} media={media}") + + predictor_id = args.predictor_id or str(predictors[0].id) + tasks = client.tasks.list(predictor_id=predictor_id) + + print(f"\nTasks for predictor {predictor_id}:") + for task in tasks: + print(f" {task.id} {task.name}") + if not tasks: + print(" (none)") + + if args.list_only: + print("\n--list-only: nothing was created.") + return 0 + + if not args.name: + print("\nPass --name to create a space.", file=sys.stderr) + return 1 + + task_ids = [t.id for t in tasks] if args.all_tasks else None + print(f"\nCreating space {args.name!r}...") + + space = client.spaces.create( + name=args.name, + predictor_id=predictor_id, + description=args.description, + is_public=args.public, + user_id=args.user_id, + organization_id=args.organization_id, + enabled_task_ids=task_ids, + ) + + print(f"\nCreated: {space.id}") + print(f" name {space.name}") + print(f" public {space.is_public}") + print(f" tasks {', '.join(space.enabled_task_names) or '-'}") + print(f"\nPut this in .env as GUARD_SPACE_ID={space.id}") + except GuardError as exc: + print(f"\nFAILED: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/list_predictors.py b/scripts/list_predictors.py new file mode 100644 index 0000000..73fbfa3 --- /dev/null +++ b/scripts/list_predictors.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Print the predictors available to your API key along with their IDs. + +A predictor is the model that powers a space. The `spaces.create` function requires a +predictor ID, making this script useful for finding one. Only the `GUARD_API_KEY` is +required. Everything else resolves through the standard client precedence using +arguments, the environment, or a `.env` file. + +The `--task-id` argument may be repeated. It narrows the results to predictors +supporting all of the given tasks. Run `scripts/list_tasks.py` to find those IDs. + +Example: + ```bash + uv run python scripts/list_predictors.py + uv run python scripts/list_predictors.py --sort-by created_at --sort-order desc + uv run python scripts/list_predictors.py --task-id --task-id + ``` +""" + +from __future__ import annotations + +import argparse +import sys + +from guard_client import GuardClient, GuardError + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + owner = parser.add_mutually_exclusive_group() + owner.add_argument("--user-id", help="only predictors available to this user") + owner.add_argument( + "--organization-id", help="only predictors available to this organization" + ) + + parser.add_argument( + "--task-id", + action="append", + metavar="UUID", + help="only predictors supporting this task; repeatable", + ) + parser.add_argument( + "--sort-by", choices=["name", "created_at"], help="server default: name" + ) + parser.add_argument( + "--sort-order", choices=["asc", "desc"], help="server default: asc" + ) + parser.add_argument( + "--limit", type=int, help="stop after this many predictors (default: all)" + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + filters = { + "user_id": args.user_id, + "organization_id": args.organization_id, + "supported_task_ids": args.task_id, + "sort_by": args.sort_by, + "sort_order": args.sort_order, + } + + try: + with GuardClient() as client: + print(f"Predictors on {client.base_url}\n") + + rows = [] + for predictor in client.predictors.iter_all(**filters): + rows.append(predictor) + if args.limit is not None and len(rows) >= args.limit: + break + + if not rows: + print("No predictors matched.") + return 0 + + width = max(len(p.name) for p in rows) + for predictor in rows: + media = "/".join(m.value for m in predictor.supported_media) or "-" + print( + f"{predictor.id} {predictor.name:{width}} " + f"x{predictor.token_multiplier} tokens media={media}" + ) + + print(f"\n{len(rows)} predictor(s).") + print("Pass one of the ids above as predictor_id when creating a space.") + except GuardError as exc: + print(f"FAILED: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/list_spaces.py b/scripts/list_spaces.py new file mode 100644 index 0000000..ff7a99e --- /dev/null +++ b/scripts/list_spaces.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +Print the spaces available to your API key along with their IDs. + +Creating an activity requires a `space_id`. This script helps you find one to place in +your `.env` file as `GUARD_SPACE_ID`. Only the `GUARD_API_KEY` is required. Everything +else resolves through the standard client precedence using arguments, the environment, +or a `.env` file. + +Using the `--default` flag steps outside your own spaces. It matches on the `is_default` +column, meaning it can return a public default space owned by someone else. + +Example: + ```bash + uv run python scripts/list_spaces.py + uv run python scripts/list_spaces.py --default + uv run python scripts/list_spaces.py --public --sort-by name + uv run python scripts/list_spaces.py --organization-id + ``` +""" + +from __future__ import annotations + +import argparse +import sys + +from guard_client import GuardClient, GuardError + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + visibility = parser.add_mutually_exclusive_group() + visibility.add_argument("--public", action="store_true", help="only public spaces") + visibility.add_argument( + "--private", action="store_true", help="only non-public spaces" + ) + + default = parser.add_mutually_exclusive_group() + default.add_argument("--default", action="store_true", help="only default spaces") + default.add_argument( + "--not-default", action="store_true", help="only non-default spaces" + ) + + owner = parser.add_mutually_exclusive_group() + owner.add_argument("--user-id", help="only spaces owned by this user") + owner.add_argument( + "--organization-id", help="only spaces owned by this organization" + ) + + parser.add_argument("--predictor-id", help="only spaces using this predictor") + parser.add_argument( + "--sort-by", choices=["name", "created_at"], help="server default: created_at" + ) + parser.add_argument( + "--sort-order", choices=["asc", "desc"], help="server default: asc" + ) + parser.add_argument( + "--limit", type=int, help="stop after this many spaces (default: all)" + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + is_public = True if args.public else (False if args.private else None) + is_default = True if args.default else (False if args.not_default else None) + filters = { + "user_id": args.user_id, + "organization_id": args.organization_id, + "predictor_id": args.predictor_id, + "is_public": is_public, + "is_default": is_default, + "sort_by": args.sort_by, + "sort_order": args.sort_order, + } + + try: + with GuardClient() as client: + print(f"Spaces on {client.base_url}\n") + + rows = [] + for space in client.spaces.iter_all(**filters): + rows.append(space) + if args.limit is not None and len(rows) >= args.limit: + break + + if not rows: + print("No spaces matched.") + return 0 + + width = max(len(s.name) for s in rows) + for space in rows: + flags = [] + if space.is_default: + flags.append("default") + flags.append("public" if space.is_public else "private") + media = "/".join(m.value for m in space.enabled_media) or "-" + print( + f"{space.id} {space.name:{width}} " + f"[{', '.join(flags)}] media={media} " + f"owner={space.owner_name or '-'}" + ) + + print(f"\n{len(rows)} space(s).") + print("Put one of the ids above in .env as GUARD_SPACE_ID.") + except GuardError as exc: + print(f"FAILED: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/list_tasks.py b/scripts/list_tasks.py new file mode 100644 index 0000000..4f82591 --- /dev/null +++ b/scripts/list_tasks.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Print the detection tasks available to your API key along with their IDs. + +A task is one detection a space can run. The `spaces.create` function takes these IDs +as `enabled_task_ids`, making this script useful for finding them. Only the +`GUARD_API_KEY` is required. Everything else resolves through the standard client +precedence using arguments, the environment, or a `.env` file. + +Names, descriptions, and reaction labels are rendered by the server in the request +locale, so using `--locale de` changes what you see. The reaction keys are the valid +`key_value` choices for `reactions.create`. + +Example: + ```bash + uv run python scripts/list_tasks.py + uv run python scripts/list_tasks.py --predictor-id + uv run python scripts/list_tasks.py --locale de --sort-by created_at + ``` +""" + +from __future__ import annotations + +import argparse +import sys + +from guard_client import GuardClient, GuardError + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + owner = parser.add_mutually_exclusive_group() + owner.add_argument("--user-id", help="only tasks available to this user") + owner.add_argument( + "--organization-id", help="only tasks available to this organization" + ) + + parser.add_argument("--predictor-id", help="only tasks this predictor supports") + parser.add_argument( + "--sort-by", choices=["name", "created_at"], help="server default: name" + ) + parser.add_argument( + "--sort-order", choices=["asc", "desc"], help="server default: asc" + ) + parser.add_argument("--locale", help="language of the labels, e.g. en or de") + parser.add_argument( + "--limit", type=int, help="stop after this many tasks (default: all)" + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + filters = { + "user_id": args.user_id, + "organization_id": args.organization_id, + "predictor_id": args.predictor_id, + "sort_by": args.sort_by, + "sort_order": args.sort_order, + } + + try: + # locale=None keeps the usual precedence: GUARD_LOCALE, then `.env`, then "en" + with GuardClient(locale=args.locale) as client: + print(f"Tasks on {client.base_url}\n") + + rows = [] + for task in client.tasks.iter_all(**filters): + rows.append(task) + if args.limit is not None and len(rows) >= args.limit: + break + + if not rows: + print("No tasks matched.") + return 0 + + width = max(len(t.name) for t in rows) + for task in rows: + reactions = ( + ", ".join(f"{k}: {v}" for k, v in sorted(task.reactions.items())) + or "-" + ) + print(f"{task.id} {task.name:{width}} reactions={{{reactions}}}") + + print(f"\n{len(rows)} task(s).") + print("Pass the ids above as enabled_task_ids when creating a space.") + except GuardError as exc: + print(f"FAILED: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/guard_client/activities.py b/src/guard_client/activities.py index 5f74e48..9121854 100644 --- a/src/guard_client/activities.py +++ b/src/guard_client/activities.py @@ -411,6 +411,21 @@ def iter_all( """ Yield every matching activity by fetching pages as needed. + Args: + user_id: Only this user's activities. This may be combined with + `organization_id`, and the API will apply both. + organization_id: Only this organization's activities. + space_id: Only activities in this space. + start_date: A `datetime`, `date`, or an ISO-8601 string. The API keeps only + one year of history. Anything older is rejected locally before the + request is sent. Omitting it defaults to exactly one year ago. A naive + datetime is interpreted as UTC to match the server. + end_date: Accepts the same types as start_date. Defaults to now. + statuses: Keep only activities with these statuses. + sort_by: Valid options include `"created_at"`. Server default: `created_at`. + sort_order: `"asc"` or `"desc"`. Server default for activities: `desc`. + page_size: Page size, 1-100. + Yields: Each matching activity, starting with the oldest page first. @@ -455,6 +470,11 @@ def wait_until_done( interval: Seconds to wait between polling requests. timeout: Maximum seconds to wait before raising an error. + Returns: + The terminal status, which is always `completed`. An activity that ended as + `failed` or `canceled` raises rather than returning, so a value coming back + from here needs no further checking. + Raises: ActivityFailedError: The activity ended as `failed` or `canceled`. GuardTimeoutError: The specified timeout seconds elapsed without reaching a diff --git a/src/guard_client/display.py b/src/guard_client/display.py index e85ce5b..36a5782 100644 --- a/src/guard_client/display.py +++ b/src/guard_client/display.py @@ -121,6 +121,11 @@ def load_media( media_type: Skips detection when you already know the type. filename: Used for detection and for naming a saved file. + Returns: + A `(data, media_type, filename)` tuple. The bytes are already in memory, so a + URL source is fetched exactly once no matter how often the result is shown or + saved afterwards. + Raises: GuardError: A result object carries no media, or a URL could not be fetched. UnsupportedMediaTypeError: The media type is not one the API accepts. diff --git a/src/guard_client/env.py b/src/guard_client/env.py index 22c0b98..3d4eb40 100644 --- a/src/guard_client/env.py +++ b/src/guard_client/env.py @@ -32,19 +32,14 @@ # The .env file supplies the key and space. result = client.analyze("photo.jpg") - GuardClient(env_file=None) # doctest: +SKIP - GuardClient(env_file=".env.staging") # doctest: +SKIP + GuardClient(env_file=None) # skip the .env file entirely + GuardClient(env_file=".env.staging") # read a different file instead ``` Note: Reading a `.env` file never writes to `os.environ`. Values are held in a plain dict on the `EnvSource`. Because of this, constructing a client cannot surprise anything else running in the same process. - -Tip: - Reading a `.env` file never writes to `os.environ`. Values are held in a plain dict - on the `EnvSource`. Because of this, constructing a client cannot surprise anything - else running in the same process. """ from __future__ import annotations diff --git a/src/guard_client/local.py b/src/guard_client/local.py index 4faba50..c88d9ea 100644 --- a/src/guard_client/local.py +++ b/src/guard_client/local.py @@ -49,11 +49,11 @@ #: keeps them reproducible across processes and releases. _LOCAL_TASK_NAMESPACE = UUID("6f9619ff-8b86-d011-b42d-00c04fc964ff") -#: Maps the excepti raises for the same mistake, rather than a local-only -#: twin of it.on name from `guard_local.exceptions` to the `GuardError` raised in +#: Maps the exception name from `guard_local.exceptions` to the `GuardError` raised in #: its place. The engine's hierarchy derives from nothing here, so without this map an #: `except GuardError` would miss every local failure. `UnsupportedMediaError` maps onto -#: the type the cloud path already +#: the type the cloud path already raises for the same mistake, rather than a local-only +#: twin of it. _LOCAL_ERROR_MAP: Dict[str, Type[GuardError]] = { "UnsupportedMediaError": UnsupportedMediaTypeError, "MediaDecodeError": GuardMediaDecodeError, diff --git a/src/guard_client/predictors.py b/src/guard_client/predictors.py index fcfb9d7..58a2dda 100644 --- a/src/guard_client/predictors.py +++ b/src/guard_client/predictors.py @@ -9,7 +9,15 @@ from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Union -from .filters import MAX_LIMIT, IdLike, add_ids, add_sort, id_list, validate_pagination +from .filters import ( + MAX_LIMIT, + IdLike, + add_ids, + add_sort, + id_list, + reject_conflicting_owners, + validate_pagination, +) from .models import Predictor, PredictorOrder, PredictorPage, SortOrder from .transport import AsyncTransport, SyncTransport @@ -57,13 +65,12 @@ def _list_params( The query dict with every unset filter omitted. Raises: - GuardError: If a filter value is invalid. + GuardError: If a filter value is invalid or both owner filters were given. """ + reject_conflicting_owners(user_id, organization_id) validate_pagination(skip, limit) params: Dict[str, Any] = {"skip": skip, "limit": limit} - # unlike spaces, this route does not reject both owner filters together, so no - # mutual-exclusion check is imposed here add_ids(params, user_id=user_id, organization_id=organization_id) task_ids = id_list(supported_task_ids, field="supported_task_ids") if task_ids: @@ -115,6 +122,9 @@ def list( Returns: A `PredictorPage`. You can iterate it like a list or read `.count`. + + Raises: + GuardError: If a filter value is invalid or both owner filters were given. """ params = self._list_params( user_id=user_id, @@ -216,6 +226,9 @@ async def list( Returns: A `PredictorPage`. Review `Predictors.list` for full filter details. + + Raises: + GuardError: If a filter value is invalid or both owner filters were given. """ params = self._list_params( user_id=user_id, diff --git a/src/guard_client/tasks.py b/src/guard_client/tasks.py index 301adee..7a0e46a 100644 --- a/src/guard_client/tasks.py +++ b/src/guard_client/tasks.py @@ -9,7 +9,14 @@ from typing import Any, AsyncIterator, Dict, Iterator, Optional, Union -from .filters import MAX_LIMIT, IdLike, add_ids, add_sort, validate_pagination +from .filters import ( + MAX_LIMIT, + IdLike, + add_ids, + add_sort, + reject_conflicting_owners, + validate_pagination, +) from .models import SortOrder, Task, TaskOrder, TaskPage from .transport import AsyncTransport, SyncTransport @@ -57,13 +64,12 @@ def _list_params( The query dict with every unset filter omitted. Raises: - GuardError: If a filter value is invalid. + GuardError: If a filter value is invalid or both owner filters were given. """ + reject_conflicting_owners(user_id, organization_id) validate_pagination(skip, limit) params: Dict[str, Any] = {"skip": skip, "limit": limit} - # unlike spaces, this route does not reject both owner filters together, so no - # mutual-exclusion check is imposed here add_ids( params, user_id=user_id, @@ -118,6 +124,9 @@ def list( Returns: A `TaskPage`. You can iterate it like a list or read `.count`. + + Raises: + GuardError: If a filter value is invalid or both owner filters were given. """ params = self._list_params( user_id=user_id, @@ -219,6 +228,9 @@ async def list( Returns: A `TaskPage`. Review `Tasks.list` for full filter details. + + Raises: + GuardError: If a filter value is invalid or both owner filters were given. """ params = self._list_params( user_id=user_id, diff --git a/tests/conftest.py b/tests/conftest.py index 4d16655..eb188c7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -325,7 +325,8 @@ def isolate_env(tmp_path_factory, monkeypatch): * exported ``GUARD_*`` variables in the shell running pytest, and * a real ``.env`` in the repo — ``find_dotenv`` walks *up* from the cwd, so once a - developer creates one for the smoke script it would otherwise leak into the suite. + developer creates one for ``scripts/create_activity.py`` it would otherwise leak + into the suite. Tests that want a ``.env`` write one into the cwd this provides. """ diff --git a/tests/test_predictors.py b/tests/test_predictors.py index 71bf4d0..526ee6d 100644 --- a/tests/test_predictors.py +++ b/tests/test_predictors.py @@ -91,17 +91,19 @@ def test_list_omits_unset_filters(client): @respx.mock -def test_list_accepts_both_owner_filters(client): +def test_conflicting_owner_filters_raise_before_any_request(client): """ - Verify that filtering by both user and organization simultaneously is accepted. + Verify that filtering by both user and organization simultaneously raises locally, + before the API answers 400 to that pair. """ route = respx.get(PREDICTORS_URL).mock( return_value=httpx.Response(200, json=page_response([])) ) - client.predictors.list(user_id=TASK_ID, organization_id=ORG_ID) + with pytest.raises(GuardError, match="both user_id and organization_id"): + client.predictors.list(user_id=TASK_ID, organization_id=ORG_ID) - assert route.called + assert not route.called @pytest.mark.parametrize( diff --git a/tests/test_tasks.py b/tests/test_tasks.py index c1c89c7..82876d5 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -83,18 +83,19 @@ def test_list_omits_unset_filters(client): @respx.mock -def test_list_accepts_both_owner_filters(client): +def test_conflicting_owner_filters_raise_before_any_request(client): """ - Ensure that passing both user_id and organization_id filters is permitted for - tasks. + Ensure specifying both user_id and organization_id filters raises an error locally + before calling the API, which answers 400 to that pair. """ route = respx.get(TASKS_URL).mock( return_value=httpx.Response(200, json=page_response([])) ) - client.tasks.list(user_id=TASK_ID, organization_id=ORG_ID) + with pytest.raises(GuardError, match="both user_id and organization_id"): + client.tasks.list(user_id=TASK_ID, organization_id=ORG_ID) - assert route.called + assert not route.called @pytest.mark.parametrize(