From 9b1e616e552d55ffe0f79fb51e3cd68e17f6e08b Mon Sep 17 00:00:00 2001 From: _ <50262751+hunzlahmalik@users.noreply.github.com> Date: Thu, 21 May 2026 13:08:10 +0500 Subject: [PATCH 1/9] Add v0.1 SDK scaffold: clients, resources, transport, CLI, tests Bootstraps the askii Python client used by SurfSense, Plane, and the MCP servers. Ships sync (Askii) + async (AsyncAskii) clients sharing one HTTPTransport, resource-oriented surface (keys, models) plus a low-level request() escape hatch, Pydantic v2 models (SecretStr-wrapped api_key), typed exception hierarchy with FieldError mapping for 422 responses, tenacity retries honoring Retry-After, pluggable Cache Protocol with InMemoryCache + optional RedisCache, opt-in JSON logging with secret redaction and correlation-ID propagation, lifecycle hooks for Prometheus /Datadog integration, and a typer-based askii CLI. Built with hatchling + hatch-vcs (git-tag-driven versioning), strict mypy in CI, ruff lint + format, pytest with 90% coverage gate, and a release workflow that attaches wheel/sdist artifacts to GitHub Releases. Co-Authored-By: Claude Opus 4.7 --- .editorconfig | 15 + .github/dependabot.yml | 22 + .github/workflows/ci.yml | 90 ++ .github/workflows/release.yml | 38 + .gitignore | 43 + .pre-commit-config.yaml | 32 + CHANGELOG.md | 23 + CLAUDE.md | 119 +++ CONTRIBUTING.md | 51 ++ README.md | 293 +++++- SECURITY.md | 27 + pyproject.toml | 136 +++ src/askii/__init__.py | 120 +++ src/askii/_cache/__init__.py | 84 ++ src/askii/_cache/memory.py | 107 +++ src/askii/_cache/redis.py | 115 +++ src/askii/_client/__init__.py | 6 + src/askii/_client/async_client.py | 134 +++ src/askii/_client/sync_client.py | 121 +++ src/askii/_config.py | 80 ++ src/askii/_errors.py | 199 +++++ src/askii/_hooks.py | 122 +++ src/askii/_logging.py | 222 +++++ src/askii/_retry.py | 82 ++ src/askii/_token.py | 108 +++ src/askii/_transport.py | 349 ++++++++ src/askii/cli/__init__.py | 8 + src/askii/cli/_app.py | 325 +++++++ src/askii/models/__init__.py | 34 + src/askii/models/_base.py | 27 + src/askii/models/_shared.py | 16 + src/askii/models/keys.py | 119 +++ src/askii/models/models.py | 22 + src/askii/py.typed | 0 src/askii/resources/__init__.py | 11 + src/askii/resources/_base.py | 30 + src/askii/resources/keys.py | 200 +++++ src/askii/resources/models.py | 50 ++ tests/__init__.py | 0 tests/conftest.py | 161 ++++ tests/unit/__init__.py | 0 tests/unit/test_cache_memory.py | 140 +++ tests/unit/test_cache_redis.py | 106 +++ tests/unit/test_cli.py | 194 ++++ tests/unit/test_clients.py | 278 ++++++ tests/unit/test_config.py | 58 ++ tests/unit/test_errors.py | 156 ++++ tests/unit/test_hooks.py | 52 ++ tests/unit/test_logging.py | 134 +++ tests/unit/test_models.py | 128 +++ tests/unit/test_retry.py | 132 +++ tests/unit/test_token_resolver.py | 101 +++ tests/unit/test_transport_async.py | 221 +++++ tests/unit/test_transport_sync.py | 121 +++ uv.lock | 1335 ++++++++++++++++++++++++++++ 55 files changed, 6896 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 pyproject.toml create mode 100644 src/askii/__init__.py create mode 100644 src/askii/_cache/__init__.py create mode 100644 src/askii/_cache/memory.py create mode 100644 src/askii/_cache/redis.py create mode 100644 src/askii/_client/__init__.py create mode 100644 src/askii/_client/async_client.py create mode 100644 src/askii/_client/sync_client.py create mode 100644 src/askii/_config.py create mode 100644 src/askii/_errors.py create mode 100644 src/askii/_hooks.py create mode 100644 src/askii/_logging.py create mode 100644 src/askii/_retry.py create mode 100644 src/askii/_token.py create mode 100644 src/askii/_transport.py create mode 100644 src/askii/cli/__init__.py create mode 100644 src/askii/cli/_app.py create mode 100644 src/askii/models/__init__.py create mode 100644 src/askii/models/_base.py create mode 100644 src/askii/models/_shared.py create mode 100644 src/askii/models/keys.py create mode 100644 src/askii/models/models.py create mode 100644 src/askii/py.typed create mode 100644 src/askii/resources/__init__.py create mode 100644 src/askii/resources/_base.py create mode 100644 src/askii/resources/keys.py create mode 100644 src/askii/resources/models.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_cache_memory.py create mode 100644 tests/unit/test_cache_redis.py create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_clients.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_errors.py create mode 100644 tests/unit/test_hooks.py create mode 100644 tests/unit/test_logging.py create mode 100644 tests/unit/test_models.py create mode 100644 tests/unit/test_retry.py create mode 100644 tests/unit/test_token_resolver.py create mode 100644 tests/unit/test_transport_async.py create mode 100644 tests/unit/test_transport_sync.py create mode 100644 uv.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2160b8f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml,toml,md}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9a946aa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + groups: + runtime: + patterns: ["httpx*", "pydantic*", "tenacity", "typer", "rich"] + dev: + patterns: ["pytest*", "ruff", "mypy*", "fakeredis", "types-*"] + redis: + patterns: ["redis"] + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..340fb45 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PYTHONUNBUFFERED: "1" + PYTHONHASHSEED: "random" + FORCE_COLOR: "1" + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv sync --extra dev --extra redis + - run: uv run ruff check . + - run: uv run ruff format --check . + + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv sync --extra dev --extra redis + - run: uv run mypy --strict src/askii + + test: + name: Test (${{ matrix.os }} / Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # hatch-vcs needs tags + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv python install ${{ matrix.python-version }} + - run: uv sync --python ${{ matrix.python-version }} --extra dev --extra redis + - run: uv run pytest --cov=askii --cov-report=term-missing --cov-report=xml --cov-fail-under=90 -v + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml + if-no-files-found: ignore + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, typecheck, test] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv build + - name: Verify py.typed in wheel + run: | + python -m zipfile -l dist/*.whl | grep "askii/py.typed" + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d5e8fa8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + release: + name: Build and publish GitHub Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # hatch-vcs needs tags + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv build + - name: Generate release notes from CHANGELOG + id: notes + run: | + python <<'PY' > release_notes.md + import re, sys, pathlib + tag = "${{ github.ref_name }}" + version = tag.lstrip("v") + text = pathlib.Path("CHANGELOG.md").read_text() + m = re.search(rf"## \[{re.escape(version)}\][^\n]*\n(.*?)(?=\n## |\Z)", text, re.S) + sys.stdout.write(m.group(1).strip() if m else f"Release {tag}") + PY + - uses: softprops/action-gh-release@v2 + with: + files: dist/* + body_path: release_notes.md + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..414b567 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +*.egg + +# Generated by hatch-vcs +src/askii/_version.py + +# Virtual envs +.venv/ +venv/ +env/ + +# uv +.uv-cache/ + +# Testing +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +coverage.xml + +# Type checkers +.mypy_cache/ +.pyright_cache/ +.ruff_cache/ + +# OS +.DS_Store +Thumbs.db + +# Editors +.idea/ +.vscode/ +*.swp +*.swo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e43c117 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: detect-private-key + - id: mixed-line-ending + args: ["--fix=lf"] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.7 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.0 + hooks: + - id: mypy + additional_dependencies: + - "pydantic>=2.6" + - "httpx>=0.27" + - "tenacity>=8.2" + - "typer>=0.12" + - "rich>=13.7" + - "types-redis" + args: ["--strict"] + files: ^src/askii/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d87b3f1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release scaffolding for the Askii Python client. +- `Askii` (sync) and `AsyncAskii` (async) clients with shared transport. +- Resource-oriented API: `client.keys.*`, `client.models.*`, plus low-level `client.request()` escape hatch. +- Pydantic v2 request/response models; `SecretStr`-wrapped provisioned `api_key`. +- Typed exception hierarchy with `FieldError` mapping for 422 responses. +- Pluggable `Cache` Protocol with `InMemoryCache` and optional `RedisCache` (`[redis]` extra). +- tenacity-based retries with exponential backoff + jitter; honors `Retry-After`. +- Lifecycle hooks (`on_request`, `on_response`, `on_retry`, `on_cache_hit`, `on_cache_miss`, `on_error`). +- Opt-in JSON logging with secret redaction and correlation-ID propagation. +- Bundled `askii` CLI (typer + rich) covering all Platform Key Management endpoints. + +[Unreleased]: https://github.com/Pressingly/askii-python/compare/v0.0.0...HEAD diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..caf35d9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,119 @@ +# CLAUDE.md + +Guidance for Claude Code (and other AI coding agents) when working in this repo. + +## What this is + +`askii-python` is the shared Python client for `https://api.askii.ai/`. It's +consumed by the Pressingly FOSS apps (SurfSense, Plane, …) and the MCP servers +that wrap them. It is intentionally the **only** place where askii HTTP details +live; consumers depend on `askii` and use its high-level resources. + +## Architecture (one-line per file) + +``` +src/askii/ +├── __init__.py # public re-exports — keep this surface intentional +├── _config.py # AskiiConfig frozen dataclass + from_env() +├── _transport.py # HTTPTransport — sync + async, cache + retry + hooks +├── _retry.py # tenacity policy (5xx / 429 / network), honors Retry-After +├── _hooks.py # Hooks dataclass + RequestEvent/ResponseEvent/RetryEvent +├── _logging.py # configure_logging(), redaction + correlation-ID filters +├── _errors.py # exception hierarchy + map_response_to_error() +├── _token.py # sync + async token resolvers (string / callable / coroutine) +├── _cache/ +│ ├── __init__.py # Cache Protocol + per-user cache-key builders +│ ├── memory.py # InMemoryCache (TTL + LRU, dual sync/async locks) +│ └── redis.py # RedisCache ([redis] extra; lazy redis import) +├── _client/ +│ ├── async_client.py # AsyncAskii (owns transport + resources) +│ └── sync_client.py # Askii +├── resources/ +│ ├── _base.py # _AsyncResource / _SyncResource bases +│ ├── keys.py # KeysResource / AsyncKeysResource — 5 of 6 endpoints +│ └── models.py # ModelsResource / AsyncModelsResource — available-models +├── models/ # Pydantic v2 request + response pairs per endpoint +└── cli/_app.py # typer-based `askii` CLI +``` + +## Design rules + +* **Passthrough auth.** The library does not acquire Cognito tokens. Callers + pass an mPass JWT (str, sync callable, or async callable). Tokens are + resolved per request. +* **Sync + async siblings.** `Askii` and `AsyncAskii` share the same transport + engine via `HTTPTransport(sync=True|False)`. Both classes mirror each other's + surface. +* **Resource-oriented + escape hatch.** New endpoints get a resource method; + ad-hoc calls go through `client.request(method, path, body=…)` which still + injects `mpass_token`. +* **Cache off by default.** `AskiiConfig` ships an `InMemoryCache(default_ttl=0)` + so no implicit caching happens. Callers opt in per call via `cache_ttl=…`. + Cache keys hash the token (per-user namespacing); the raw token never appears + in a key. +* **Strict typing.** `mypy --strict` in CI, `py.typed` in the wheel, + `extra="forbid"` on every Pydantic model so upstream schema drift surfaces + as test failures, not silent breakage. +* **Logging never force-configured.** The library uses `logging.getLogger("askii")` + and inherits consumer config. `configure_logging()` is opt-in. + +## When extending + +### Adding a new endpoint + +1. Add Pydantic request + response models under `src/askii/models/.py`. +2. Add resource methods to `resources/.py` (both async and sync). +3. Wire cache key + TTL (or skip caching) and invalidation if it's a mutation. +4. Re-export from `models/__init__.py` and `src/askii/__init__.py`. +5. Add a unit test for the model round-trip and the resource method (sync + async). +6. Add a CLI command if it's user-facing. +7. Update `CHANGELOG.md` under `## [Unreleased]`. + +### Adding a new resource namespace + +Mirror the `keys` / `models` resources. Each namespace gets a sync + async pair +with the same surface, and the parent client constructor instantiates both. + +### Cache invalidation + +After a mutating call: + +```python +await self._client._ainvalidate(_RESOURCE) # async resource +self._client._invalidate(_RESOURCE) # sync resource +``` + +This deletes all cache entries under the current user's resource prefix. + +## Local verification loop + +```bash +uv run ruff check . +uv run ruff format --check . +uv run mypy --strict src/askii +uv run pytest --cov=askii --cov-fail-under=90 -v +uv build +``` + +All four must pass before opening a PR. + +## Things to avoid + +* Don't add new transitive deps without weighing against `pip install askii`'s + install-time cost. We're consumed by multiple Django/FastAPI services. +* Don't add new top-level modules; keep internal helpers under `_*` so the + public surface stays the explicit re-exports in `__init__.py`. +* Don't widen `extra="forbid"` to `"allow"` on response models — the strict + setting is the early-warning for upstream schema drift. +* Don't add `from askii._cache.redis import RedisCache` to the public + `__init__.py`; keep it opt-in so consumers without the extra don't accidentally + trigger the runtime import error. +* Don't print or log tokens directly. The redaction filter catches most cases, + but the right pattern is `SecretStr` on response models and never logging the + raw resolved token. + +## Reference + +* Plan that bootstrapped this repo: see the original implementation plan (kept + with the initial commit history). +* Upstream API docs: https://api.askii.ai/docs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a93c9b8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing + +Thanks for taking time to contribute. This client is consumed by production +services, so we keep the bar high. + +## Local development + +```bash +uv venv +source .venv/bin/activate +uv pip install -e ".[dev,redis]" +pre-commit install +``` + +## The expected loop + +```bash +uv run ruff check . +uv run ruff format --check . +uv run mypy --strict src/askii +uv run pytest --cov=askii --cov-fail-under=90 -v +``` + +All four must pass before opening a PR. + +## Style + +- Python ≥ 3.10. Use `from __future__ import annotations` when it materially + improves readability. +- `ruff` rules `E, F, I, UP, B`; line length 120; double quotes. +- Google-style docstrings on public classes and methods. +- Public API additions need: a Pydantic request/response pair, resource method + (sync + async), unit test coverage, and a `CHANGELOG.md` entry under + `## [Unreleased]`. + +## Tests + +- Default suite runs against `httpx.MockTransport` — no network. +- Mark tests that need a live backend with `@pytest.mark.integration`; they're + skipped in the default run. +- New code must keep total coverage at or above 90%. + +## Releases + +Tag `vX.Y.Z` on `main` once `CHANGELOG.md`'s `Unreleased` section is moved into +a versioned heading. Hatch-vcs derives `askii.__version__` from the tag. + +## Reporting issues + +Open an issue at https://github.com/Pressingly/askii-python/issues. For security +reports, follow `SECURITY.md` instead. diff --git a/README.md b/README.md index e590104..58d7bb2 100644 --- a/README.md +++ b/README.md @@ -1 +1,292 @@ -# askii-python \ No newline at end of file +# askii — Python client for the Askii platform API + +A production-grade Python SDK for [`api.askii.ai`](https://api.askii.ai/docs/), +intended to be the single point of contact between Pressingly's FOSS apps +(SurfSense, Plane, Outline, Penpot, …) and the Askii platform. + +* **Sync + async** — `Askii` and `AsyncAskii` siblings, same surface. +* **Resource-oriented** — `client.keys.list()`, `client.models.list()`, etc., + plus a low-level `client.request()` escape hatch for unwrapped endpoints. +* **Typed end-to-end** — Pydantic v2 models, `mypy --strict` in CI, `py.typed` + ships in the wheel. +* **Production stack** — pluggable cache, tenacity retries with jitter, + rich exception hierarchy, opt-in JSON logging with secret redaction, + Prometheus/Datadog-friendly lifecycle hooks. +* **CLI included** — `askii keys list`, `askii keys provision …`, etc. + +--- + +## Install + +Pin against a tag in your consumer's `pyproject.toml`: + +```toml +[project] +dependencies = [ + "askii @ git+https://github.com/Pressingly/askii-python.git@v0.1.0", +] +``` + +Or, with the optional Redis cache: + +```toml +"askii[redis] @ git+https://github.com/Pressingly/askii-python.git@v0.1.0", +``` + +`uv` and `pip` both honor git URL specifiers. + +--- + +## Quickstart + +### Async (FastAPI, FastMCP, surfsense_backend, …) + +```python +from askii import AsyncAskii + +async with AsyncAskii(token=mpass_jwt) as client: + keys = await client.keys.list() + for k in keys.keys: + print(k.key_name, k.spend, k.default_model) + + new = await client.keys.provision(key_alias="surfsense", duration_days=30) + print(new.api_key.get_secret_value()) # unwrap SecretStr + + available = await client.models.list(cache_ttl=300) # opt-in cache +``` + +### Sync (Django, scripts, ops tools) + +```python +from askii import Askii + +with Askii(token=mpass_jwt) as client: + cfg = client.keys.get_config(key="surfsense") + client.keys.update_model(key="surfsense", models=["gpt-4o", "claude-sonnet-4"]) +``` + +### Low-level escape hatch — endpoints not yet wrapped + +```python +raw: dict = await client.request( + "POST", + "/platform/some-future-endpoint", + body={"foo": "bar"}, +) +``` + +`mpass_token` is injected for you on every request. + +--- + +## Authentication (passthrough) + +The library does **not** acquire Cognito tokens itself. You pass a fresh +mPass JWT in one of three shapes: + +```python +Askii(token="ey...") # static string +Askii(token=lambda: load_token()) # sync callable, called per request +AsyncAskii(token=async_token_provider) # async callable / coroutine factory +``` + +The callable forms let you plug in refresh logic, AWS SSM lookups, or whatever +your auth flow needs. + +Sync `Askii` rejects coroutine factories at construction with `AskiiAuthError` +— use `AsyncAskii` if you need async resolution. + +--- + +## Configuration + +```python +from askii import AskiiConfig, Hooks, AsyncAskii + +cfg = AskiiConfig.from_env( + base_url="https://api.askii.ai", # or $ASKII_BASE_URL + timeout=30.0, # or $ASKII_TIMEOUT_SECONDS + max_retries=3, # or $ASKII_MAX_RETRIES + http2=True, + hooks=Hooks(...), # see below +) +client = AsyncAskii(token=jwt, config=cfg) +``` + +Recognized environment variables: + +| Variable | Maps to | Default | +|---|---|---| +| `ASKII_BASE_URL` | `base_url` | `https://api.askii.ai` | +| `ASKII_TOKEN` | `token` | — | +| `ASKII_TIMEOUT_SECONDS` | `timeout` | `30.0` | +| `ASKII_MAX_RETRIES` | `max_retries` | `3` | + +--- + +## Caching (off by default) + +Cache is **off by default** to avoid surprise stale reads. Opt in per call: + +```python +keys = await client.keys.list(cache_ttl=60) # 1-minute cache +cfg = await client.keys.get_config(key="x", cache_ttl=30) +mdls = await client.models.list(cache_ttl=300) # 5-minute cache +``` + +Mutating calls (`provision`, `revoke`, `update_model`) invalidate the +relevant resource's cache automatically. + +### Pluggable backends + +```python +from askii import AskiiConfig, InMemoryCache +from askii._cache.redis import RedisCache # requires [redis] extra + +# In-process default +cfg = AskiiConfig(cache=InMemoryCache(maxsize=2048)) + +# Shared Redis (across processes / services) +cfg = AskiiConfig(cache=RedisCache(url="redis://valkey:6379/12", namespace="askii")) +``` + +Cache keys are per-user (derived from a SHA-256 hash of the resolved token — +**never the token itself**), so a shared `RedisCache` is safe across tenants. + +You can also implement the `Cache` Protocol against any backend you like; it +covers both sync and async paths. + +--- + +## Errors + +```text +AskiiError +├── AskiiTransportError +│ ├── AskiiConnectionError +│ └── AskiiTimeoutError +└── AskiiAPIError(status, detail, request_id, response) + ├── AskiiAuthError # 401, 403 + ├── AskiiNotFoundError # 404 + ├── AskiiValidationError # 422 — field_errors: list[FieldError] + ├── AskiiRateLimitError # 429 — retry_after (s) when present + └── AskiiServerError # 5xx +``` + +422 responses are parsed into typed `FieldError(loc, msg, type)` entries so +callers can produce structured error messages: + +```python +try: + await client.keys.provision(duration_days=999) +except AskiiValidationError as exc: + for fe in exc.field_errors: + log.warning("%s: %s", fe.path, fe.msg) # body.duration_days: out of range +``` + +5xx / 429 / connection / timeout errors are retried by the built-in tenacity +policy (max 3 attempts, exponential backoff with jitter, honors `Retry-After`). +4xx errors are not retried. + +--- + +## Hooks and metrics + +```python +from askii import Hooks, RequestEvent, ResponseEvent + +def record_request(e: RequestEvent) -> None: + metrics.counter("askii.requests").inc(method=e.method, path=e.path) + +def record_response(e: ResponseEvent) -> None: + metrics.histogram("askii.latency_ms").observe(e.elapsed_ms, status=e.status_code) + +hooks = Hooks( + on_request=record_request, + on_response=record_response, + on_retry=lambda e: log.warning("askii retry %d after %.2fs", e.attempt, e.sleep_seconds), +) +client = AsyncAskii(token=jwt, config=AskiiConfig(hooks=hooks)) +``` + +Callback exceptions are caught and logged at WARNING — they never break the +underlying call. + +--- + +## Logging (opt-in) + +The library never force-configures Python logging. Inherits whatever the +consumer has set up. If you want askii's JSON formatter with secret redaction +and correlation-ID propagation: + +```python +import askii + +askii.configure_logging(level="INFO", json=True) +``` + +Redaction masks `mpass_token`, `api_key`, `Authorization`, `sk-…`, and JWT +patterns in both log messages and `extra={}` fields. + +Bind a correlation ID across an async flow: + +```python +from askii import bind_correlation_id, reset_correlation_id + +token = bind_correlation_id("request-123") +try: + await client.keys.list() +finally: + reset_correlation_id(token) +``` + +The correlation ID is added to every log record and propagated to the upstream +as `X-Correlation-ID`. + +--- + +## CLI + +```bash +askii keys list +askii keys provision --alias surfsense --duration-days 30 -m gpt-4o +askii keys revoke --key surfsense +askii keys get-config --key surfsense +askii keys update-model --key surfsense -m gpt-4o -m claude-sonnet-4 --default-model gpt-4o +askii models list +askii version +``` + +* Token comes from `--token` or `$ASKII_TOKEN`. +* Base URL comes from `--base-url` or `$ASKII_BASE_URL`. +* Output: `--output table` (default for `list`) or `--output json`. + +Exit codes: `0` ok · `2` auth · `3` validation · `4` transport · `5` server · +`6` other API · `99` unexpected. + +--- + +## Development + +```bash +git clone https://github.com/Pressingly/askii-python +cd askii-python +uv venv +uv sync --extra dev --extra redis +uv run pre-commit install + +uv run ruff check . && uv run ruff format --check . +uv run mypy --strict src/askii +uv run pytest --cov=askii --cov-fail-under=90 -v +uv build +``` + +See `CONTRIBUTING.md` for the contribution loop and `SECURITY.md` for +vulnerability reporting. + +--- + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8ed160a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security policy + +## Reporting a vulnerability + +Please **do not** open a public GitHub issue for security reports. + +Email `security@pressingly.com` with: + +- A description of the issue and its impact. +- Steps to reproduce, including a minimal proof-of-concept where possible. +- The version of `askii` you're using (`python -c "import askii; print(askii.__version__)"`). +- Any disclosure timeline constraints on your side. + +We'll acknowledge receipt within two business days and aim to ship a fix +(or coordinated disclosure plan) within 30 days for confirmed issues. + +## Scope + +- Token handling, secret redaction in logs, and request signing. +- Authentication bypass via the client surface or CLI. +- Cache poisoning across users or processes. +- Dependency vulnerabilities surfaced by Dependabot or `pip-audit`. + +Out of scope (open an issue instead): + +- Bugs in the upstream Askii API itself (report to the API team). +- Misconfiguration in a consumer's own deployment. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7a1a92b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,136 @@ +[build-system] +requires = ["hatchling>=1.21", "hatch-vcs>=0.4"] +build-backend = "hatchling.build" + +[project] +name = "askii" +dynamic = ["version"] +description = "Production-grade Python client for the Askii platform API." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Pressingly" }] +keywords = ["askii", "litellm", "moneta", "mpass", "api-client", "sdk"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "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", + "Programming Language :: Python :: Implementation :: CPython", + "Typing :: Typed", +] +dependencies = [ + "httpx[http2]>=0.27", + "pydantic>=2.6", + "tenacity>=8.2", + "typer>=0.12", + "rich>=13.7", +] + +[project.optional-dependencies] +redis = ["redis>=5.0"] +dev = [ + "pytest>=7.4", + "pytest-asyncio>=0.23", + "pytest-cov>=4.1", + "pytest-recording>=0.13", + "fakeredis>=2.21", + "ruff>=0.5", + "mypy>=1.10", + "types-redis", +] + +[project.scripts] +askii = "askii.cli._app:app" + +[project.urls] +Homepage = "https://github.com/Pressingly/askii-python" +Repository = "https://github.com/Pressingly/askii-python" +Issues = "https://github.com/Pressingly/askii-python/issues" +Changelog = "https://github.com/Pressingly/askii-python/blob/main/CHANGELOG.md" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "src/askii/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/askii"] + +[tool.hatch.build.targets.sdist] +include = ["src/askii", "tests", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.ruff] +line-length = 120 +target-version = "py310" +src = ["src", "tests"] +extend-exclude = ["src/askii/_version.py"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = [] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" + +[tool.mypy] +strict = true +python_version = "3.10" +files = ["src/askii"] +plugins = ["pydantic.mypy"] +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true + +[[tool.mypy.overrides]] +module = ["tests.*"] +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[[tool.mypy.overrides]] +module = ["fakeredis.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "--strict-markers", + "--strict-config", +] +markers = [ + "integration: marks tests requiring network or fixtures (deselect with -m 'not integration')", +] +filterwarnings = ["error"] + +[tool.coverage.run] +source = ["src/askii"] +branch = true +omit = ["src/askii/_version.py"] + +[tool.coverage.report] +exclude_also = [ + "if TYPE_CHECKING:", + "raise NotImplementedError", + "@overload", + "pragma: no cover", +] +show_missing = true +skip_covered = false diff --git a/src/askii/__init__.py b/src/askii/__init__.py new file mode 100644 index 0000000..b11eee6 --- /dev/null +++ b/src/askii/__init__.py @@ -0,0 +1,120 @@ +"""Askii — the Python client for the Askii platform API. + +Quickstart:: + + from askii import AsyncAskii + + async with AsyncAskii(token=mpass_jwt) as client: + keys = await client.keys.list() + for k in keys.keys: + print(k.key_name, k.spend) + +See ``README.md`` for full usage, configuration, caching, hooks, and CLI. +""" + +from askii._cache import Cache, build_cache_key, build_resource_prefix +from askii._cache.memory import InMemoryCache +from askii._client.async_client import AsyncAskii +from askii._client.sync_client import Askii +from askii._config import ( + DEFAULT_BASE_URL, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT_SECONDS, + AskiiConfig, +) +from askii._errors import ( + AskiiAPIError, + AskiiAuthError, + AskiiConnectionError, + AskiiError, + AskiiNotFoundError, + AskiiRateLimitError, + AskiiServerError, + AskiiTimeoutError, + AskiiTransportError, + AskiiValidationError, + FieldError, +) +from askii._hooks import Hooks, RequestEvent, ResponseEvent, RetryEvent +from askii._logging import ( + bind_correlation_id, + configure_logging, + get_correlation_id, + reset_correlation_id, +) +from askii._token import TokenSource +from askii.models import ( + AvailableModel, + AvailableModelsResponse, + GetKeyConfigRequest, + KeyConfig, + KeyInfo, + ListKeysResponse, + MemoryMode, + ProvisionKeyRequest, + ProvisionKeyResponse, + RevokeKeyRequest, + RevokeKeyResponse, + UpdateKeyModelRequest, + UpdateKeyModelResponse, +) + +try: # generated by hatch-vcs at build time + from askii._version import __version__ +except ImportError: # pragma: no cover — present after build, missing during raw checkout + __version__ = "0.0.0+local" + +__all__ = [ + # Clients + "AsyncAskii", + "Askii", + # Configuration + "AskiiConfig", + "TokenSource", + "DEFAULT_BASE_URL", + "DEFAULT_TIMEOUT_SECONDS", + "DEFAULT_MAX_RETRIES", + # Hooks / events + "Hooks", + "RequestEvent", + "ResponseEvent", + "RetryEvent", + # Cache + "Cache", + "InMemoryCache", + "build_cache_key", + "build_resource_prefix", + # Logging + "configure_logging", + "bind_correlation_id", + "reset_correlation_id", + "get_correlation_id", + # Errors + "AskiiError", + "AskiiTransportError", + "AskiiConnectionError", + "AskiiTimeoutError", + "AskiiAPIError", + "AskiiAuthError", + "AskiiNotFoundError", + "AskiiValidationError", + "AskiiRateLimitError", + "AskiiServerError", + "FieldError", + # Models + "MemoryMode", + "ProvisionKeyRequest", + "ProvisionKeyResponse", + "KeyInfo", + "ListKeysResponse", + "RevokeKeyRequest", + "RevokeKeyResponse", + "GetKeyConfigRequest", + "KeyConfig", + "UpdateKeyModelRequest", + "UpdateKeyModelResponse", + "AvailableModel", + "AvailableModelsResponse", + # Misc + "__version__", +] diff --git a/src/askii/_cache/__init__.py b/src/askii/_cache/__init__.py new file mode 100644 index 0000000..6b60dd6 --- /dev/null +++ b/src/askii/_cache/__init__.py @@ -0,0 +1,84 @@ +"""Pluggable cache layer for the askii client. + +The :class:`Cache` Protocol covers both sync and async paths so a single backend +instance can serve both :class:`askii.Askii` and :class:`askii.AsyncAskii`. + +Two backends ship in-box: + +* :class:`askii._cache.memory.InMemoryCache` — always available. +* :class:`askii._cache.redis.RedisCache` — requires the ``[redis]`` extra. + +Cache keys are built by :func:`build_cache_key` so all backends agree on the +shape: ``askii:v1:{user_hash}:{resource}:{op}:{args_hash}``. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any, Protocol, runtime_checkable + +CACHE_VERSION = "v1" +CACHE_PREFIX = "askii" + + +@runtime_checkable +class Cache(Protocol): + """A cache that supports both synchronous and asynchronous access. + + Implementations must be safe to share between threads and event loops. + A ``ttl`` of zero (or less) must be treated as a no-op for writes. + """ + + def get(self, key: str) -> Any | None: ... + def set(self, key: str, value: Any, *, ttl: float) -> None: ... + def delete(self, key: str) -> None: ... + def delete_prefix(self, prefix: str) -> None: ... + def clear(self) -> None: ... + + async def aget(self, key: str) -> Any | None: ... + async def aset(self, key: str, value: Any, *, ttl: float) -> None: ... + async def adelete(self, key: str) -> None: ... + async def adelete_prefix(self, prefix: str) -> None: ... + async def aclear(self) -> None: ... + + +def _hash8(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:8] + + +def _canonical_args(args: dict[str, Any] | None) -> str: + if not args: + return "" + return json.dumps(args, sort_keys=True, separators=(",", ":"), default=str) + + +def build_user_prefix(token: str) -> str: + """Per-user namespace prefix; never contains the token itself.""" + return f"{CACHE_PREFIX}:{CACHE_VERSION}:{_hash8(token)}" + + +def build_resource_prefix(token: str, resource: str) -> str: + """Per-resource prefix for bulk invalidation.""" + return f"{build_user_prefix(token)}:{resource}" + + +def build_cache_key( + token: str, + resource: str, + op: str, + args: dict[str, Any] | None = None, +) -> str: + """Build a stable cache key for one resource operation.""" + args_hash = _hash8(_canonical_args(args)) if args else "noargs" + return f"{build_resource_prefix(token, resource)}:{op}:{args_hash}" + + +__all__ = [ + "Cache", + "CACHE_PREFIX", + "CACHE_VERSION", + "build_cache_key", + "build_resource_prefix", + "build_user_prefix", +] diff --git a/src/askii/_cache/memory.py b/src/askii/_cache/memory.py new file mode 100644 index 0000000..d3c0ac7 --- /dev/null +++ b/src/askii/_cache/memory.py @@ -0,0 +1,107 @@ +"""In-process TTL + LRU cache. Default backend; always available.""" + +from __future__ import annotations + +import asyncio +import threading +import time +from collections import OrderedDict +from typing import Any + +DEFAULT_MAXSIZE = 1024 + + +class InMemoryCache: + """Thread- and asyncio-safe TTL cache with LRU eviction. + + ``ttl<=0`` on :meth:`set` / :meth:`aset` is a no-op — the value is dropped + rather than stored. This makes ``InMemoryCache(default_ttl=0)`` an + "off by default" cache, which is the library default. + """ + + def __init__(self, maxsize: int = DEFAULT_MAXSIZE, default_ttl: float = 0.0) -> None: + if maxsize <= 0: + raise ValueError("maxsize must be positive") + self._maxsize = maxsize + self._default_ttl = default_ttl + self._store: OrderedDict[str, tuple[float, Any]] = OrderedDict() + self._lock = threading.RLock() + self._alock = asyncio.Lock() + + # --- sync API ----------------------------------------------------- + + def get(self, key: str) -> Any | None: + with self._lock: + return self._get_locked(key) + + def set(self, key: str, value: Any, *, ttl: float) -> None: + effective_ttl = ttl if ttl > 0 else self._default_ttl + if effective_ttl <= 0: + return + expiry = time.monotonic() + effective_ttl + with self._lock: + self._set_locked(key, value, expiry) + + def delete(self, key: str) -> None: + with self._lock: + self._store.pop(key, None) + + def delete_prefix(self, prefix: str) -> None: + with self._lock: + for key in [k for k in self._store if k.startswith(prefix)]: + del self._store[key] + + def clear(self) -> None: + with self._lock: + self._store.clear() + + # --- async API ---------------------------------------------------- + + async def aget(self, key: str) -> Any | None: + async with self._alock: + with self._lock: + return self._get_locked(key) + + async def aset(self, key: str, value: Any, *, ttl: float) -> None: + effective_ttl = ttl if ttl > 0 else self._default_ttl + if effective_ttl <= 0: + return + expiry = time.monotonic() + effective_ttl + async with self._alock: + with self._lock: + self._set_locked(key, value, expiry) + + async def adelete(self, key: str) -> None: + async with self._alock: + self.delete(key) + + async def adelete_prefix(self, prefix: str) -> None: + async with self._alock: + self.delete_prefix(prefix) + + async def aclear(self) -> None: + async with self._alock: + self.clear() + + # --- internals ---------------------------------------------------- + + def _get_locked(self, key: str) -> Any | None: + entry = self._store.get(key) + if entry is None: + return None + expiry, value = entry + if expiry < time.monotonic(): + del self._store[key] + return None + self._store.move_to_end(key) + return value + + def _set_locked(self, key: str, value: Any, expiry: float) -> None: + if key in self._store: + self._store.move_to_end(key) + self._store[key] = (expiry, value) + while len(self._store) > self._maxsize: + self._store.popitem(last=False) + + +__all__ = ["InMemoryCache", "DEFAULT_MAXSIZE"] diff --git a/src/askii/_cache/redis.py b/src/askii/_cache/redis.py new file mode 100644 index 0000000..4881960 --- /dev/null +++ b/src/askii/_cache/redis.py @@ -0,0 +1,115 @@ +"""Redis-backed cache. Requires the ``[redis]`` install extra. + +Values are JSON-encoded so any consumer can read them regardless of language, +and so a poisoned cache cannot execute arbitrary Python (no pickle). +""" + +from __future__ import annotations + +import json +from typing import Any + + +class RedisCache: + """A :class:`askii._cache.Cache`-shaped wrapper over a real Redis client. + + Pass either a connection URL (and we'll build the clients) or pre-built + sync + async clients. The namespace prefix is added to every key so a + single Redis instance can serve multiple services without collision. + """ + + def __init__( + self, + url: str | None = None, + *, + namespace: str = "", + sync_client: Any = None, + async_client: Any = None, + scan_count: int = 500, + ) -> None: + try: + import redis as _redis + import redis.asyncio as _aredis + except ImportError as exc: # pragma: no cover — exercised by integration + raise RuntimeError( + "RedisCache requires the optional 'redis' extra. Install with: pip install 'askii[redis]'" + ) from exc + + if sync_client is None and async_client is None and url is None: + raise ValueError("Provide a redis URL or pre-built clients") + + self._sync: Any = sync_client if sync_client is not None else _redis.Redis.from_url(url or "") + self._async: Any = async_client if async_client is not None else _aredis.Redis.from_url(url or "") + self._ns = namespace.rstrip(":") + ":" if namespace else "" + self._scan_count = scan_count + + # --- sync --------------------------------------------------------- + + def get(self, key: str) -> Any | None: + raw = self._sync.get(self._k(key)) + return _decode(raw) + + def set(self, key: str, value: Any, *, ttl: float) -> None: + if ttl <= 0: + return + self._sync.set(self._k(key), _encode(value), ex=max(1, int(ttl))) + + def delete(self, key: str) -> None: + self._sync.delete(self._k(key)) + + def delete_prefix(self, prefix: str) -> None: + pattern = self._k(prefix) + "*" + for key in self._sync.scan_iter(match=pattern, count=self._scan_count): + self._sync.delete(key) + + def clear(self) -> None: + if not self._ns: + raise RuntimeError("Refusing to FLUSH a shared Redis without a namespace") + self.delete_prefix("") + + # --- async -------------------------------------------------------- + + async def aget(self, key: str) -> Any | None: + raw = await self._async.get(self._k(key)) + return _decode(raw) + + async def aset(self, key: str, value: Any, *, ttl: float) -> None: + if ttl <= 0: + return + await self._async.set(self._k(key), _encode(value), ex=max(1, int(ttl))) + + async def adelete(self, key: str) -> None: + await self._async.delete(self._k(key)) + + async def adelete_prefix(self, prefix: str) -> None: + pattern = self._k(prefix) + "*" + async for key in self._async.scan_iter(match=pattern, count=self._scan_count): + await self._async.delete(key) + + async def aclear(self) -> None: + if not self._ns: + raise RuntimeError("Refusing to FLUSH a shared Redis without a namespace") + await self.adelete_prefix("") + + # --- internals ---------------------------------------------------- + + def _k(self, key: str) -> str: + return f"{self._ns}{key}" + + +def _encode(value: Any) -> bytes: + return json.dumps(value, separators=(",", ":"), default=str).encode("utf-8") + + +def _decode(raw: bytes | str | None) -> Any | None: + if raw is None: + return None + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +__all__ = ["RedisCache"] diff --git a/src/askii/_client/__init__.py b/src/askii/_client/__init__.py new file mode 100644 index 0000000..8d0bc8c --- /dev/null +++ b/src/askii/_client/__init__.py @@ -0,0 +1,6 @@ +"""Client classes that drive the askii transport.""" + +from askii._client.async_client import AsyncAskii +from askii._client.sync_client import Askii + +__all__ = ["Askii", "AsyncAskii"] diff --git a/src/askii/_client/async_client.py b/src/askii/_client/async_client.py new file mode 100644 index 0000000..1801259 --- /dev/null +++ b/src/askii/_client/async_client.py @@ -0,0 +1,134 @@ +"""Async client (``AsyncAskii``).""" + +from __future__ import annotations + +from types import TracebackType +from typing import TYPE_CHECKING, Any + +from askii._cache import build_cache_key, build_resource_prefix +from askii._config import AskiiConfig +from askii._token import AsyncResolver, make_async_resolver +from askii._transport import HTTPTransport +from askii.resources.keys import AsyncKeysResource +from askii.resources.models import AsyncModelsResource + +if TYPE_CHECKING: + import httpx + + from askii._token import TokenSource + + +class AsyncAskii: + """Asynchronous client for the Askii platform API. + + Pass either a static ``mpass_token`` string, a sync callable that returns + a fresh token, or an async callable / coroutine factory. The token is + resolved on every request, so callable resolvers can implement refresh. + + Example:: + + async with AsyncAskii(token=jwt) as client: + keys = await client.keys.list() + print(keys.user_id) + """ + + def __init__( + self, + token: TokenSource | None = None, + *, + config: AskiiConfig | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + self._config = config or AskiiConfig.from_env() + resolver_source = token if token is not None else self._config.token + self._resolver: AsyncResolver = make_async_resolver(resolver_source) + self._transport = HTTPTransport(self._config, sync=False, http_client=http_client) + self.keys = AsyncKeysResource(self) + self.models = AsyncModelsResource(self) + + # ------------------------------------------------------------------ + # public escape hatch + # ------------------------------------------------------------------ + + async def request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + cache_ttl: float | None = None, + cache_resource: str | None = None, + cache_op: str | None = None, + cache_args: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Send an arbitrary request to the Askii API. + + The ``mpass_token`` is injected for you. Provide ``cache_resource`` + + ``cache_op`` + ``cache_ttl`` if you want the response cached. + """ + return await self._arequest( + method, + path, + body=body or {}, + cache_resource=cache_resource, + cache_op=cache_op, + cache_args=cache_args, + cache_ttl=cache_ttl, + ) + + # ------------------------------------------------------------------ + # internal helpers used by resources + # ------------------------------------------------------------------ + + async def _arequest( + self, + method: str, + path: str, + *, + body: dict[str, Any], + cache_resource: str | None = None, + cache_op: str | None = None, + cache_args: dict[str, Any] | None = None, + cache_ttl: float | None = None, + ) -> dict[str, Any]: + token = await self._resolver() + body_with_token: dict[str, Any] = {"mpass_token": token, **body} + cache_key: str | None = None + if cache_resource and cache_op and cache_ttl and cache_ttl > 0: + cache_key = build_cache_key(token, cache_resource, cache_op, cache_args) + return await self._transport.arequest( + method, + path, + body=body_with_token, + cache_key=cache_key, + cache_ttl=cache_ttl, + ) + + async def _ainvalidate(self, resource: str) -> None: + token = await self._resolver() + await self._config.cache.adelete_prefix(build_resource_prefix(token, resource)) + + @property + def config(self) -> AskiiConfig: + return self._config + + # ------------------------------------------------------------------ + # lifecycle + # ------------------------------------------------------------------ + + async def aclose(self) -> None: + await self._transport.aclose() + + async def __aenter__(self) -> AsyncAskii: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + +__all__ = ["AsyncAskii"] diff --git a/src/askii/_client/sync_client.py b/src/askii/_client/sync_client.py new file mode 100644 index 0000000..9857e9e --- /dev/null +++ b/src/askii/_client/sync_client.py @@ -0,0 +1,121 @@ +"""Sync client (``Askii``).""" + +from __future__ import annotations + +from types import TracebackType +from typing import TYPE_CHECKING, Any + +from askii._cache import build_cache_key, build_resource_prefix +from askii._config import AskiiConfig +from askii._token import SyncResolver, make_sync_resolver +from askii._transport import HTTPTransport +from askii.resources.keys import KeysResource +from askii.resources.models import ModelsResource + +if TYPE_CHECKING: + import httpx + + from askii._token import TokenSource + + +class Askii: + """Synchronous client for the Askii platform API. + + Pass either a static ``mpass_token`` string or a sync callable. Async + callables are rejected — use :class:`AsyncAskii` instead. + + Example:: + + with Askii(token=jwt) as client: + keys = client.keys.list() + print(keys.user_id) + """ + + def __init__( + self, + token: TokenSource | None = None, + *, + config: AskiiConfig | None = None, + http_client: httpx.Client | None = None, + ) -> None: + self._config = config or AskiiConfig.from_env() + resolver_source = token if token is not None else self._config.token + self._resolver: SyncResolver = make_sync_resolver(resolver_source) + self._transport = HTTPTransport(self._config, sync=True, http_client=http_client) + self.keys = KeysResource(self) + self.models = ModelsResource(self) + + def request( + self, + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + cache_ttl: float | None = None, + cache_resource: str | None = None, + cache_op: str | None = None, + cache_args: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Send an arbitrary request to the Askii API. + + The ``mpass_token`` is injected for you. Provide ``cache_resource`` + + ``cache_op`` + ``cache_ttl`` if you want the response cached. + """ + return self._request( + method, + path, + body=body or {}, + cache_resource=cache_resource, + cache_op=cache_op, + cache_args=cache_args, + cache_ttl=cache_ttl, + ) + + def _request( + self, + method: str, + path: str, + *, + body: dict[str, Any], + cache_resource: str | None = None, + cache_op: str | None = None, + cache_args: dict[str, Any] | None = None, + cache_ttl: float | None = None, + ) -> dict[str, Any]: + token = self._resolver() + body_with_token: dict[str, Any] = {"mpass_token": token, **body} + cache_key: str | None = None + if cache_resource and cache_op and cache_ttl and cache_ttl > 0: + cache_key = build_cache_key(token, cache_resource, cache_op, cache_args) + return self._transport.request( + method, + path, + body=body_with_token, + cache_key=cache_key, + cache_ttl=cache_ttl, + ) + + def _invalidate(self, resource: str) -> None: + token = self._resolver() + self._config.cache.delete_prefix(build_resource_prefix(token, resource)) + + @property + def config(self) -> AskiiConfig: + return self._config + + def close(self) -> None: + self._transport.close() + + def __enter__(self) -> Askii: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close() + + +__all__ = ["Askii"] diff --git a/src/askii/_config.py b/src/askii/_config.py new file mode 100644 index 0000000..65b2646 --- /dev/null +++ b/src/askii/_config.py @@ -0,0 +1,80 @@ +"""Frozen-dataclass configuration for the Askii client.""" + +from __future__ import annotations + +import os +import platform +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from askii._hooks import Hooks +from askii._token import TokenSource + +if TYPE_CHECKING: + from askii._cache import Cache + +DEFAULT_BASE_URL = "https://api.askii.ai" +DEFAULT_TIMEOUT_SECONDS = 30.0 +DEFAULT_MAX_RETRIES = 3 + + +def _default_cache() -> Cache: + from askii._cache.memory import InMemoryCache + + return InMemoryCache(default_ttl=0.0) + + +def _default_user_agent() -> str: + try: + from askii._version import __version__ + except ImportError: + __version__ = "0.0.0+local" + return f"askii-python/{__version__} python/{platform.python_version()}" + + +@dataclass(frozen=True, slots=True) +class AskiiConfig: + """Immutable runtime configuration for the Askii client. + + Construct directly or via :meth:`from_env`. All fields have sensible defaults + so callers can pass nothing and still get a working client. + """ + + base_url: str = DEFAULT_BASE_URL + timeout: float = DEFAULT_TIMEOUT_SECONDS + max_retries: int = DEFAULT_MAX_RETRIES + http2: bool = True + token: TokenSource | None = None + cache: Cache = field(default_factory=_default_cache) + hooks: Hooks = field(default_factory=Hooks) + default_cache_ttl: float = 0.0 + user_agent: str = field(default_factory=_default_user_agent) + verify: bool | str = True + + @classmethod + def from_env(cls, **overrides: Any) -> AskiiConfig: + """Build a config, picking up `ASKII_*` env vars as fallbacks. + + Recognized variables: + + - ``ASKII_BASE_URL`` + - ``ASKII_TOKEN`` + - ``ASKII_TIMEOUT_SECONDS`` + - ``ASKII_MAX_RETRIES`` + + Explicit kwargs always win over env vars. + """ + env_kwargs: dict[str, Any] = { + "base_url": os.getenv("ASKII_BASE_URL", DEFAULT_BASE_URL), + } + if (token := os.getenv("ASKII_TOKEN")) is not None: + env_kwargs["token"] = token + if (raw := os.getenv("ASKII_TIMEOUT_SECONDS")) is not None: + env_kwargs["timeout"] = float(raw) + if (raw := os.getenv("ASKII_MAX_RETRIES")) is not None: + env_kwargs["max_retries"] = int(raw) + env_kwargs.update(overrides) + return cls(**env_kwargs) + + +__all__ = ["AskiiConfig", "DEFAULT_BASE_URL", "DEFAULT_TIMEOUT_SECONDS", "DEFAULT_MAX_RETRIES"] diff --git a/src/askii/_errors.py b/src/askii/_errors.py new file mode 100644 index 0000000..408b43f --- /dev/null +++ b/src/askii/_errors.py @@ -0,0 +1,199 @@ +"""Exception hierarchy and HTTP-response → exception mapper.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import httpx + + +@dataclass(frozen=True, slots=True) +class FieldError: + """A single field-level validation error returned by the upstream API.""" + + loc: tuple[str | int, ...] + msg: str + type: str + + @property + def path(self) -> str: + """Dotted path of the offending field (e.g. ``body.duration_days``).""" + return ".".join(str(part) for part in self.loc) + + +class AskiiError(Exception): + """Base class for every exception raised by the askii client.""" + + +class AskiiTransportError(AskiiError): + """Network-layer failure before a response was received.""" + + +class AskiiConnectionError(AskiiTransportError): + """The TCP/TLS connection failed or was reset.""" + + +class AskiiTimeoutError(AskiiTransportError): + """The request timed out before completing.""" + + +class AskiiAPIError(AskiiError): + """The server returned a non-2xx response.""" + + def __init__( + self, + status: int, + detail: Any, + *, + request_id: str | None = None, + response: httpx.Response | None = None, + ) -> None: + self.status = status + self.detail = detail + self.request_id = request_id + self.response = response + super().__init__(self._format()) + + def _format(self) -> str: + head = f"{self.__class__.__name__}: HTTP {self.status}" + rid = f" (request_id={self.request_id})" if self.request_id else "" + return f"{head}{rid}: {self.detail!r}" + + +class AskiiAuthError(AskiiAPIError): + """401 or 403 from the upstream API, or missing local credentials.""" + + +class AskiiNotFoundError(AskiiAPIError): + """404 from the upstream API.""" + + +class AskiiValidationError(AskiiAPIError): + """422 from the upstream API; ``field_errors`` carries the typed details.""" + + def __init__( + self, + status: int, + detail: Any, + *, + field_errors: list[FieldError], + request_id: str | None = None, + response: httpx.Response | None = None, + ) -> None: + self.field_errors = field_errors + super().__init__(status, detail, request_id=request_id, response=response) + + +class AskiiRateLimitError(AskiiAPIError): + """429 from the upstream API; ``retry_after`` is seconds when known.""" + + def __init__( + self, + status: int, + detail: Any, + *, + retry_after: float | None = None, + request_id: str | None = None, + response: httpx.Response | None = None, + ) -> None: + self.retry_after = retry_after + super().__init__(status, detail, request_id=request_id, response=response) + + +class AskiiServerError(AskiiAPIError): + """5xx from the upstream API; retried by default.""" + + +def _parse_field_errors(detail: Any) -> list[FieldError]: + if not isinstance(detail, list): + return [] + out: list[FieldError] = [] + for item in detail: + if not isinstance(item, dict): + continue + loc = item.get("loc") or () + if isinstance(loc, list): + loc_tuple = tuple(loc) + elif isinstance(loc, tuple): + loc_tuple = loc + else: + loc_tuple = (loc,) + out.append( + FieldError( + loc=loc_tuple, + msg=str(item.get("msg", "")), + type=str(item.get("type", "")), + ) + ) + return out + + +def _parse_retry_after(response: httpx.Response) -> float | None: + raw = response.headers.get("retry-after") + if not raw: + return None + try: + return float(raw) + except ValueError: + return None + + +def _request_id_from(response: httpx.Response) -> str | None: + for header in ("x-request-id", "x-correlation-id", "request-id"): + value: str | None = response.headers.get(header) + if value: + return value + return None + + +def map_response_to_error(response: httpx.Response) -> AskiiAPIError: + """Translate a non-2xx ``httpx.Response`` into the right typed exception.""" + status = response.status_code + request_id = _request_id_from(response) + try: + body = response.json() + except Exception: + body = response.text + detail = body.get("detail", body) if isinstance(body, dict) else body + + if status in (401, 403): + return AskiiAuthError(status, detail, request_id=request_id, response=response) + if status == 404: + return AskiiNotFoundError(status, detail, request_id=request_id, response=response) + if status == 422: + return AskiiValidationError( + status, + detail, + field_errors=_parse_field_errors(detail), + request_id=request_id, + response=response, + ) + if status == 429: + return AskiiRateLimitError( + status, + detail, + retry_after=_parse_retry_after(response), + request_id=request_id, + response=response, + ) + if 500 <= status < 600: + return AskiiServerError(status, detail, request_id=request_id, response=response) + return AskiiAPIError(status, detail, request_id=request_id, response=response) + + +__all__ = [ + "AskiiError", + "AskiiTransportError", + "AskiiConnectionError", + "AskiiTimeoutError", + "AskiiAPIError", + "AskiiAuthError", + "AskiiNotFoundError", + "AskiiValidationError", + "AskiiRateLimitError", + "AskiiServerError", + "FieldError", + "map_response_to_error", +] diff --git a/src/askii/_hooks.py b/src/askii/_hooks.py new file mode 100644 index 0000000..eabb3ad --- /dev/null +++ b/src/askii/_hooks.py @@ -0,0 +1,122 @@ +"""Lifecycle hooks for observability and metrics integration. + +Hook callbacks are plain callables, never coroutines (sync + async paths share +them). Exceptions raised inside a callback are caught and logged at WARNING so +they never break the underlying request. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger("askii.hooks") + + +@dataclass(frozen=True, slots=True) +class RequestEvent: + """Emitted just before the HTTP request is sent (per attempt).""" + + method: str + path: str + correlation_id: str + attempt: int + headers: Mapping[str, str] + + +@dataclass(frozen=True, slots=True) +class ResponseEvent: + """Emitted after the HTTP response is received (per attempt, success or not).""" + + method: str + path: str + correlation_id: str + attempt: int + status_code: int + elapsed_ms: float + request_id: str | None + + +@dataclass(frozen=True, slots=True) +class RetryEvent: + """Emitted when the retry policy decides to sleep before another attempt.""" + + method: str + path: str + correlation_id: str + attempt: int + sleep_seconds: float + exception: BaseException + + +@dataclass(slots=True) +class Hooks: + """Container for optional lifecycle callbacks. + + All callbacks are optional. Each is invoked synchronously from both the sync + and async transports; if a callback raises, the exception is swallowed and + logged at WARNING. + """ + + on_request: Callable[[RequestEvent], None] | None = None + on_response: Callable[[ResponseEvent], None] | None = None + on_retry: Callable[[RetryEvent], None] | None = None + on_cache_hit: Callable[[str], None] | None = None + on_cache_miss: Callable[[str], None] | None = None + on_error: Callable[[BaseException], None] | None = None + + def dispatch(self, callback: Callable[..., None] | None, *args: Any) -> None: + if callback is None: + return + try: + callback(*args) + except Exception: # noqa: BLE001 — hooks must never poison the call + logger.warning("askii hook callback raised; swallowed", exc_info=True) + + +_EMPTY_HEADERS: Mapping[str, str] = {} + + +def empty_headers() -> Mapping[str, str]: + """Return a stable empty headers mapping for event construction.""" + return _EMPTY_HEADERS + + +@dataclass(slots=True) +class _HooksProxy: + """Convenience wrapper that adds typed dispatch methods. + + The transport calls this rather than `Hooks` directly so the call sites stay + readable. + """ + + hooks: Hooks = field(default_factory=Hooks) + + def request(self, event: RequestEvent) -> None: + self.hooks.dispatch(self.hooks.on_request, event) + + def response(self, event: ResponseEvent) -> None: + self.hooks.dispatch(self.hooks.on_response, event) + + def retry(self, event: RetryEvent) -> None: + self.hooks.dispatch(self.hooks.on_retry, event) + + def cache_hit(self, key: str) -> None: + self.hooks.dispatch(self.hooks.on_cache_hit, key) + + def cache_miss(self, key: str) -> None: + self.hooks.dispatch(self.hooks.on_cache_miss, key) + + def error(self, exc: BaseException) -> None: + self.hooks.dispatch(self.hooks.on_error, exc) + + +__all__ = [ + "Hooks", + "RequestEvent", + "ResponseEvent", + "RetryEvent", + "empty_headers", +] diff --git a/src/askii/_logging.py b/src/askii/_logging.py new file mode 100644 index 0000000..347bd01 --- /dev/null +++ b/src/askii/_logging.py @@ -0,0 +1,222 @@ +"""Logging utilities for the askii client. + +The library never force-configures logging on import. Consumers call +:func:`configure_logging` once at boot time if they want askii's opinionated +JSON handler. Without that call, askii logs propagate to whatever the consumer +has configured. + +Two filters do the security-sensitive work: + +* :class:`RedactionFilter` masks known-secret keys and inline ``sk-`` tokens + in log records. +* :class:`CorrelationIdFilter` attaches the current contextvar correlation ID + to every record so JSON output and consumer filters can pivot on it. +""" + +from __future__ import annotations + +import json +import logging +import re +import uuid +from contextvars import ContextVar, Token +from datetime import datetime, timezone +from typing import Any + +LOGGER_NAME = "askii" +_REDACTED = "***redacted***" +_REDACT_KEYS = frozenset( + { + "mpass_token", + "api_key", + "apikey", + "authorization", + "x-api-key", + "token", + "access_token", + "refresh_token", + "id_token", + } +) +_INLINE_SECRETS = re.compile(r"sk-[A-Za-z0-9_-]{16,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+") + +_correlation_id_var: ContextVar[str | None] = ContextVar("askii_correlation_id", default=None) + +_STANDARD_RECORD_ATTRS = frozenset( + { + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "message", + "module", + "msecs", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "thread", + "threadName", + "taskName", + } +) + + +def get_correlation_id() -> str | None: + """Return the current correlation ID, if one is bound.""" + return _correlation_id_var.get() + + +def bind_correlation_id(value: str | None = None) -> Token[str | None]: + """Bind a correlation ID for the current async/thread context. + + Returns a token that can be passed to :func:`reset_correlation_id` to + restore the previous value. + """ + return _correlation_id_var.set(value or uuid.uuid4().hex) + + +def reset_correlation_id(token: Token[str | None]) -> None: + """Restore the correlation ID to its prior value.""" + _correlation_id_var.reset(token) + + +class CorrelationIdFilter(logging.Filter): + """Attach the current contextvar correlation ID to every log record.""" + + def filter(self, record: logging.LogRecord) -> bool: + record.correlation_id = _correlation_id_var.get() + return True + + +class RedactionFilter(logging.Filter): + """Mask known-secret fields in log records before they're formatted.""" + + def filter(self, record: logging.LogRecord) -> bool: + if isinstance(record.msg, str): + record.msg = _redact_inline(record.msg) + if isinstance(record.args, dict): + record.args = _redact_value(record.args) + elif isinstance(record.args, tuple): + record.args = tuple(_redact_value(a) for a in record.args) + for attr in list(record.__dict__): + if attr in _STANDARD_RECORD_ATTRS or attr == "correlation_id": + continue + record.__dict__[attr] = _redact_value(record.__dict__[attr], top_key=attr) + return True + + +def _redact_inline(text: str) -> str: + return _INLINE_SECRETS.sub(_REDACTED, text) + + +def _redact_value(value: Any, *, top_key: str | None = None) -> Any: + if top_key and top_key.lower() in _REDACT_KEYS and value is not None: + return _REDACTED + if isinstance(value, str): + return _redact_inline(value) + if isinstance(value, dict): + return {k: _redact_value(v, top_key=str(k)) for k, v in value.items()} + if isinstance(value, (list, tuple)): + rendered = [_redact_value(v) for v in value] + return type(value)(rendered) if isinstance(value, tuple) else rendered + return value + + +class JsonFormatter(logging.Formatter): + """Render log records as one JSON object per line. + + Picks up arbitrary ``extra={}`` fields, the bound correlation ID, exception + info, and stack info. + """ + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + if cid := getattr(record, "correlation_id", None): + payload["correlation_id"] = cid + for attr, value in record.__dict__.items(): + if attr in _STANDARD_RECORD_ATTRS or attr in payload or attr == "correlation_id": + continue + if attr.startswith("_"): + continue + payload[attr] = value + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + if record.stack_info: + payload["stack"] = record.stack_info + return json.dumps(payload, default=str, ensure_ascii=False) + + +def configure_logging( + *, + level: str | int = "INFO", + json: bool = True, + include_payloads: bool = False, + propagate: bool = False, +) -> logging.Logger: + """Install askii's JSON handler on the ``askii`` logger. + + Idempotent: re-calling replaces the existing handler. + + Args: + level: Log level for the ``askii`` logger. + json: If True, use the JSON formatter. If False, use a single-line + human-readable formatter. + include_payloads: Stored on the logger as ``askii_include_payloads`` + so the transport can decide whether to emit request/response + bodies at DEBUG. + propagate: Whether to bubble askii records up to the root logger. + Default False keeps consumer-app logs unmolested. + + Returns: + The configured ``askii`` logger. + """ + logger = logging.getLogger(LOGGER_NAME) + for handler in list(logger.handlers): + logger.removeHandler(handler) + + handler = logging.StreamHandler() + if json: + handler.setFormatter(JsonFormatter()) + else: + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s [%(correlation_id)s] %(message)s")) + handler.addFilter(CorrelationIdFilter()) + handler.addFilter(RedactionFilter()) + + logger.addHandler(handler) + logger.setLevel(level) + logger.propagate = propagate + logger.askii_include_payloads = bool(include_payloads) # type: ignore[attr-defined] + return logger + + +def get_logger(name: str = LOGGER_NAME) -> logging.Logger: + """Return a logger under the ``askii`` namespace.""" + return logging.getLogger(name) + + +__all__ = [ + "LOGGER_NAME", + "JsonFormatter", + "CorrelationIdFilter", + "RedactionFilter", + "bind_correlation_id", + "reset_correlation_id", + "get_correlation_id", + "configure_logging", + "get_logger", +] diff --git a/src/askii/_retry.py b/src/askii/_retry.py new file mode 100644 index 0000000..a9b3c24 --- /dev/null +++ b/src/askii/_retry.py @@ -0,0 +1,82 @@ +"""Tenacity retry policy used by the sync and async transports. + +Both transports drive the *same* policy. The only difference is the runner — +:class:`tenacity.AsyncRetrying` vs :class:`tenacity.Retrying`. +""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +from tenacity import ( + AsyncRetrying, + Retrying, + retry_if_exception_type, + stop_after_attempt, +) +from tenacity.wait import wait_base + +from askii._errors import ( + AskiiConnectionError, + AskiiRateLimitError, + AskiiServerError, + AskiiTimeoutError, +) + +if TYPE_CHECKING: + from tenacity import RetryCallState + + from askii._config import AskiiConfig + +RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = ( + AskiiServerError, + AskiiRateLimitError, + AskiiConnectionError, + AskiiTimeoutError, +) + + +class _AskiiWait(wait_base): + """Exponential backoff with jitter; honors ``Retry-After`` from 429s.""" + + def __init__(self, initial: float = 0.1, max_wait: float = 2.0) -> None: + self._initial = initial + self._max = max_wait + + def __call__(self, retry_state: RetryCallState) -> float: + outcome = retry_state.outcome + if outcome is not None and outcome.failed: + exc = outcome.exception() + if isinstance(exc, AskiiRateLimitError) and exc.retry_after: + return min(float(exc.retry_after), 60.0) + attempt = retry_state.attempt_number + backoff = self._initial * (2 ** max(0, attempt - 1)) + backoff = min(backoff, self._max) + return float(backoff * (0.5 + random.random() / 2)) # noqa: S311 — jitter, not crypto + + +def _build_kwargs(config: AskiiConfig) -> dict[str, object]: + return { + "stop": stop_after_attempt(max(1, config.max_retries)), + "wait": _AskiiWait(initial=0.1, max_wait=2.0), + "retry": retry_if_exception_type(RETRYABLE_EXCEPTIONS), + "reraise": True, + } + + +def build_async_retry(config: AskiiConfig) -> AsyncRetrying: + """Build the async retry runner for a given config.""" + return AsyncRetrying(**_build_kwargs(config)) # type: ignore[arg-type] + + +def build_sync_retry(config: AskiiConfig) -> Retrying: + """Build the sync retry runner for a given config.""" + return Retrying(**_build_kwargs(config)) # type: ignore[arg-type] + + +__all__ = [ + "RETRYABLE_EXCEPTIONS", + "build_async_retry", + "build_sync_retry", +] diff --git a/src/askii/_token.py b/src/askii/_token.py new file mode 100644 index 0000000..aaddbe6 --- /dev/null +++ b/src/askii/_token.py @@ -0,0 +1,108 @@ +"""Token resolvers — accept a string, a sync callable, or an async callable.""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable +from typing import TypeAlias + +from askii._errors import AskiiAuthError + +TokenSource: TypeAlias = str | Callable[[], str] | Callable[[], Awaitable[str]] + +AsyncResolver = Callable[[], Awaitable[str]] +SyncResolver = Callable[[], str] + + +def _ensure_token(value: str) -> str: + if not isinstance(value, str) or not value: + raise AskiiAuthError( + status=0, + detail="Token resolver returned an empty or non-string value", + ) + return value + + +def make_async_resolver(source: TokenSource | None) -> AsyncResolver: + """Build a coroutine resolver from a string, sync callable, or async callable.""" + if source is None: + raise AskiiAuthError(status=0, detail="No mpass_token provided") + + if isinstance(source, str): + token = _ensure_token(source) + + async def _static() -> str: + return token + + return _static + + if callable(source): + + async def _dynamic() -> str: + value = source() + if inspect.isawaitable(value): + value = await value + return _ensure_token(value) + + return _dynamic + + raise AskiiAuthError( + status=0, + detail=f"Unsupported token source: {type(source).__name__}", + ) + + +def make_sync_resolver(source: TokenSource | None) -> SyncResolver: + """Build a synchronous resolver. Rejects coroutine factories.""" + if source is None: + raise AskiiAuthError(status=0, detail="No mpass_token provided") + + if isinstance(source, str): + token = _ensure_token(source) + + def _static() -> str: + return token + + return _static + + if inspect.iscoroutinefunction(source) or inspect.isasyncgenfunction(source): + raise AskiiAuthError( + status=0, + detail=( + "Sync Askii client received an async token callable. " + "Use AsyncAskii or supply a sync callable / static string." + ), + ) + + if callable(source): + + def _dynamic() -> str: + value = source() + if inspect.isawaitable(value): + # Close the coroutine cleanly to keep "unawaited coroutine" warnings out. + if hasattr(value, "close"): + value.close() + raise AskiiAuthError( + status=0, + detail=( + "Sync Askii client received an async token callable. " + "Use AsyncAskii or supply a sync callable / static string." + ), + ) + return _ensure_token(value) + + return _dynamic + + raise AskiiAuthError( + status=0, + detail=f"Unsupported token source: {type(source).__name__}", + ) + + +__all__ = [ + "TokenSource", + "AsyncResolver", + "SyncResolver", + "make_async_resolver", + "make_sync_resolver", +] diff --git a/src/askii/_transport.py b/src/askii/_transport.py new file mode 100644 index 0000000..f4fe4fa --- /dev/null +++ b/src/askii/_transport.py @@ -0,0 +1,349 @@ +"""HTTP transport — the engine that powers both sync and async clients. + +A single :class:`HTTPTransport` instance is either sync- or async-flavored +(its httpx client is fixed at construction). Both flavors share every other +concern: header construction, correlation IDs, cache check + write, retry +policy, response parsing, error mapping, and hook dispatch. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +import httpx + +from askii._errors import ( + AskiiConnectionError, + AskiiTimeoutError, + AskiiTransportError, + map_response_to_error, +) +from askii._hooks import RequestEvent, ResponseEvent, RetryEvent, _HooksProxy +from askii._logging import ( + bind_correlation_id, + get_correlation_id, + get_logger, + reset_correlation_id, +) +from askii._retry import build_async_retry, build_sync_retry + +if TYPE_CHECKING: + from contextvars import Token + + from askii._cache import Cache + from askii._config import AskiiConfig + +logger = get_logger("askii.transport") + + +class HTTPTransport: + """Engine that owns one httpx client and runs requests with retries. + + Construct with ``sync=True`` for :class:`httpx.Client` or ``sync=False`` + for :class:`httpx.AsyncClient`. The other path will raise if invoked. + """ + + def __init__( + self, + config: AskiiConfig, + *, + sync: bool, + http_client: httpx.Client | httpx.AsyncClient | None = None, + ) -> None: + self._cfg = config + self._sync = sync + self._owns_client = http_client is None + self._client_sync: httpx.Client | None + self._client_async: httpx.AsyncClient | None + if sync: + if http_client is not None and not isinstance(http_client, httpx.Client): + raise TypeError("sync=True requires an httpx.Client (got AsyncClient)") + self._client_sync = http_client or self._build_sync_client() + self._client_async = None + else: + if http_client is not None and not isinstance(http_client, httpx.AsyncClient): + raise TypeError("sync=False requires an httpx.AsyncClient (got Client)") + self._client_async = http_client or self._build_async_client() + self._client_sync = None + self._hooks = _HooksProxy(config.hooks) + self._cache: Cache = config.cache + + # ------------------------------------------------------------------ + # construction helpers + # ------------------------------------------------------------------ + + def _common_client_kwargs(self) -> dict[str, Any]: + return { + "base_url": self._cfg.base_url, + "timeout": httpx.Timeout(self._cfg.timeout), + "http2": self._cfg.http2, + "verify": self._cfg.verify, + "headers": { + "User-Agent": self._cfg.user_agent, + "Accept": "application/json", + "Content-Type": "application/json", + }, + } + + def _build_sync_client(self) -> httpx.Client: + return httpx.Client(**self._common_client_kwargs()) + + def _build_async_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient(**self._common_client_kwargs()) + + # ------------------------------------------------------------------ + # async path + # ------------------------------------------------------------------ + + async def arequest( + self, + method: str, + path: str, + *, + body: dict[str, Any], + cache_key: str | None = None, + cache_ttl: float | None = None, + ) -> dict[str, Any]: + """Run an async request with cache + retry + hooks.""" + if self._sync or self._client_async is None: + raise RuntimeError("HTTPTransport was constructed with sync=True") + + if cache_key: + cached = await self._cache.aget(cache_key) + if cached is not None: + self._hooks.cache_hit(cache_key) + return cached # type: ignore[no-any-return] + self._hooks.cache_miss(cache_key) + + correlation_token, correlation_id = self._ensure_correlation_id() + try: + payload = await self._arun_with_retries(method, path, body, correlation_id) + except BaseException as exc: + self._hooks.error(exc) + raise + finally: + if correlation_token is not None: + reset_correlation_id(correlation_token) + + if cache_key and cache_ttl and cache_ttl > 0: + await self._cache.aset(cache_key, payload, ttl=cache_ttl) + return payload + + async def _arun_with_retries( + self, + method: str, + path: str, + body: dict[str, Any], + correlation_id: str, + ) -> dict[str, Any]: + retrying = build_async_retry(self._cfg) + last_payload: dict[str, Any] | None = None + async for attempt in retrying: + with attempt: + attempt_num = attempt.retry_state.attempt_number + if attempt_num > 1 and attempt.retry_state.outcome is not None: + self._emit_retry_event(method, path, correlation_id, attempt_num, attempt.retry_state) + last_payload = await self._asend(method, path, body, correlation_id, attempt_num) + assert last_payload is not None + return last_payload + + async def _asend( + self, + method: str, + path: str, + body: dict[str, Any], + correlation_id: str, + attempt: int, + ) -> dict[str, Any]: + assert self._client_async is not None + headers = {"X-Correlation-ID": correlation_id} + self._hooks.request(RequestEvent(method, path, correlation_id, attempt, headers)) + start = time.monotonic() + try: + response = await self._client_async.request(method, path, json=body, headers=headers) + except httpx.TimeoutException as exc: + raise AskiiTimeoutError(str(exc)) from exc + except httpx.ConnectError as exc: + raise AskiiConnectionError(str(exc)) from exc + except httpx.NetworkError as exc: + raise AskiiTransportError(str(exc)) from exc + return self._parse(method, path, correlation_id, attempt, response, start) + + # ------------------------------------------------------------------ + # sync path + # ------------------------------------------------------------------ + + def request( + self, + method: str, + path: str, + *, + body: dict[str, Any], + cache_key: str | None = None, + cache_ttl: float | None = None, + ) -> dict[str, Any]: + """Run a sync request with cache + retry + hooks.""" + if not self._sync or self._client_sync is None: + raise RuntimeError("HTTPTransport was constructed with sync=False") + + if cache_key: + cached = self._cache.get(cache_key) + if cached is not None: + self._hooks.cache_hit(cache_key) + return cached # type: ignore[no-any-return] + self._hooks.cache_miss(cache_key) + + correlation_token, correlation_id = self._ensure_correlation_id() + try: + payload = self._run_with_retries(method, path, body, correlation_id) + except BaseException as exc: + self._hooks.error(exc) + raise + finally: + if correlation_token is not None: + reset_correlation_id(correlation_token) + + if cache_key and cache_ttl and cache_ttl > 0: + self._cache.set(cache_key, payload, ttl=cache_ttl) + return payload + + def _run_with_retries( + self, + method: str, + path: str, + body: dict[str, Any], + correlation_id: str, + ) -> dict[str, Any]: + retrying = build_sync_retry(self._cfg) + last_payload: dict[str, Any] | None = None + for attempt in retrying: + with attempt: + attempt_num = attempt.retry_state.attempt_number + if attempt_num > 1 and attempt.retry_state.outcome is not None: + self._emit_retry_event(method, path, correlation_id, attempt_num, attempt.retry_state) + last_payload = self._send(method, path, body, correlation_id, attempt_num) + assert last_payload is not None + return last_payload + + def _send( + self, + method: str, + path: str, + body: dict[str, Any], + correlation_id: str, + attempt: int, + ) -> dict[str, Any]: + assert self._client_sync is not None + headers = {"X-Correlation-ID": correlation_id} + self._hooks.request(RequestEvent(method, path, correlation_id, attempt, headers)) + start = time.monotonic() + try: + response = self._client_sync.request(method, path, json=body, headers=headers) + except httpx.TimeoutException as exc: + raise AskiiTimeoutError(str(exc)) from exc + except httpx.ConnectError as exc: + raise AskiiConnectionError(str(exc)) from exc + except httpx.NetworkError as exc: + raise AskiiTransportError(str(exc)) from exc + return self._parse(method, path, correlation_id, attempt, response, start) + + # ------------------------------------------------------------------ + # shared helpers + # ------------------------------------------------------------------ + + def _parse( + self, + method: str, + path: str, + correlation_id: str, + attempt: int, + response: httpx.Response, + start: float, + ) -> dict[str, Any]: + elapsed_ms = (time.monotonic() - start) * 1000.0 + request_id = response.headers.get("x-request-id") or response.headers.get("request-id") + self._hooks.response( + ResponseEvent( + method=method, + path=path, + correlation_id=correlation_id, + attempt=attempt, + status_code=response.status_code, + elapsed_ms=elapsed_ms, + request_id=request_id, + ) + ) + logger.info( + "askii.http", + extra={ + "method": method, + "path": path, + "status": response.status_code, + "latency_ms": round(elapsed_ms, 2), + "attempt": attempt, + "request_id": request_id, + }, + ) + if response.status_code >= 400: + raise map_response_to_error(response) + if response.status_code == 204 or not response.content: + return {} + try: + data = response.json() + except ValueError as exc: + raise AskiiTransportError(f"Upstream returned non-JSON response: {exc}") from exc + if not isinstance(data, dict): + return {"value": data} + return data + + def _emit_retry_event( + self, + method: str, + path: str, + correlation_id: str, + attempt: int, + retry_state: Any, + ) -> None: + outcome = retry_state.outcome + if outcome is None or not outcome.failed: + return + exc = outcome.exception() + if exc is None: + return + sleep_seconds = float(getattr(retry_state.next_action, "sleep", 0.0) or 0.0) + self._hooks.retry( + RetryEvent( + method=method, + path=path, + correlation_id=correlation_id, + attempt=attempt, + sleep_seconds=sleep_seconds, + exception=exc, + ) + ) + + @staticmethod + def _ensure_correlation_id() -> tuple[Token[str | None] | None, str]: + existing = get_correlation_id() + if existing is not None: + return None, existing + token = bind_correlation_id() + current = get_correlation_id() + assert current is not None # bind_correlation_id always sets a value + return token, current + + # ------------------------------------------------------------------ + # lifecycle + # ------------------------------------------------------------------ + + async def aclose(self) -> None: + if self._owns_client and self._client_async is not None: + await self._client_async.aclose() + + def close(self) -> None: + if self._owns_client and self._client_sync is not None: + self._client_sync.close() + + +__all__ = ["HTTPTransport"] diff --git a/src/askii/cli/__init__.py b/src/askii/cli/__init__.py new file mode 100644 index 0000000..39b8861 --- /dev/null +++ b/src/askii/cli/__init__.py @@ -0,0 +1,8 @@ +"""Typer-based ``askii`` command-line interface. + +Run ``askii --help`` after installing the package. +""" + +from askii.cli._app import app, main + +__all__ = ["app", "main"] diff --git a/src/askii/cli/_app.py b/src/askii/cli/_app.py new file mode 100644 index 0000000..be9ffc7 --- /dev/null +++ b/src/askii/cli/_app.py @@ -0,0 +1,325 @@ +"""``askii`` CLI implementation.""" + +from __future__ import annotations + +import json +import sys +from enum import Enum +from typing import Annotated, Any + +import typer +from pydantic import BaseModel +from rich.console import Console +from rich.table import Table + +from askii import ( + Askii, + AskiiAPIError, + AskiiAuthError, + AskiiConfig, + AskiiError, + AskiiServerError, + AskiiTransportError, + AskiiValidationError, + __version__, +) +from askii.models import MemoryMode + +EXIT_OK = 0 +EXIT_USAGE = 1 +EXIT_AUTH = 2 +EXIT_VALIDATION = 3 +EXIT_TRANSPORT = 4 +EXIT_SERVER = 5 +EXIT_API = 6 +EXIT_UNEXPECTED = 99 + +stdout = Console() +stderr = Console(stderr=True) + + +class OutputFmt(str, Enum): + """How to render command output.""" + + TABLE = "table" + JSON = "json" + + +app = typer.Typer(no_args_is_help=True, add_completion=False, help="Askii platform CLI.") +keys_app = typer.Typer(no_args_is_help=True, help="Manage Askii virtual keys.") +models_app = typer.Typer(no_args_is_help=True, help="Inspect available models.") +app.add_typer(keys_app, name="keys") +app.add_typer(models_app, name="models") + + +# --------------------------------------------------------------------------- +# common options +# --------------------------------------------------------------------------- + +TokenOpt = Annotated[ + str | None, + typer.Option( + "--token", + envvar="ASKII_TOKEN", + help="mPass OIDC JWT. Falls back to $ASKII_TOKEN.", + show_envvar=True, + ), +] +BaseUrlOpt = Annotated[ + str | None, + typer.Option( + "--base-url", + envvar="ASKII_BASE_URL", + help="Askii API base URL. Falls back to $ASKII_BASE_URL.", + show_envvar=True, + ), +] +OutputOpt = Annotated[ + OutputFmt, + typer.Option( + "--output", + "-o", + case_sensitive=False, + help="Output format.", + ), +] + + +def _build_client(token: str | None, base_url: str | None) -> Askii: + if not token: + stderr.print("[red]No token provided. Pass --token or set $ASKII_TOKEN.[/red]") + raise typer.Exit(code=EXIT_AUTH) + overrides: dict[str, Any] = {"token": token} + if base_url: + overrides["base_url"] = base_url + return Askii(config=AskiiConfig.from_env(**overrides)) + + +def _to_dict(value: Any) -> Any: + if isinstance(value, BaseModel): + return value.model_dump(mode="json") + return value + + +def _render(value: Any, output: OutputFmt, *, table: Table | None = None) -> None: + if output is OutputFmt.JSON: + stdout.print_json(json.dumps(_to_dict(value), default=str)) + return + if table is not None: + stdout.print(table) + return + stdout.print_json(json.dumps(_to_dict(value), default=str)) + + +def _handle(func: Any) -> Any: + """Decorator: translate askii exceptions into stable exit codes.""" + import functools + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + except AskiiAuthError as exc: + stderr.print(f"[red]Authentication failed:[/red] {exc}") + raise typer.Exit(code=EXIT_AUTH) from exc + except AskiiValidationError as exc: + stderr.print(f"[red]Validation error:[/red] {exc}") + for fe in exc.field_errors: + stderr.print(f" - {fe.path}: {fe.msg} ({fe.type})") + raise typer.Exit(code=EXIT_VALIDATION) from exc + except AskiiServerError as exc: + stderr.print(f"[red]Server error:[/red] {exc}") + raise typer.Exit(code=EXIT_SERVER) from exc + except AskiiTransportError as exc: + stderr.print(f"[red]Transport error:[/red] {exc}") + raise typer.Exit(code=EXIT_TRANSPORT) from exc + except AskiiAPIError as exc: + stderr.print(f"[red]API error:[/red] {exc}") + raise typer.Exit(code=EXIT_API) from exc + except AskiiError as exc: + stderr.print(f"[red]Askii error:[/red] {exc}") + raise typer.Exit(code=EXIT_UNEXPECTED) from exc + + return wrapper + + +# --------------------------------------------------------------------------- +# keys subcommands +# --------------------------------------------------------------------------- + + +@keys_app.command("list") +@_handle +def keys_list( + token: TokenOpt = None, + base_url: BaseUrlOpt = None, + output: OutputOpt = OutputFmt.TABLE, +) -> None: + """List active virtual keys for the authenticated user.""" + with _build_client(token, base_url) as client: + resp = client.keys.list() + if output is OutputFmt.TABLE: + table = Table(title=f"Keys for {resp.user_id}") + table.add_column("Name") + table.add_column("Alias") + table.add_column("Default model") + table.add_column("Spend", justify="right") + table.add_column("Expires") + table.add_column("Memory") + for k in resp.keys: + table.add_row( + k.key_name, + k.key_alias or "—", + k.default_model or "—", + f"{k.spend:.4f}", + k.expires.isoformat() if k.expires else "—", + (k.memory_mode.value if k.memory_mode else ("on" if k.memory_enabled else "off")), + ) + _render(resp, output, table=table) + else: + _render(resp, output) + + +@keys_app.command("provision") +@_handle +def keys_provision( + token: TokenOpt = None, + base_url: BaseUrlOpt = None, + alias: Annotated[str | None, typer.Option("--alias", help="Human-readable key alias.")] = None, + duration_days: Annotated[int, typer.Option("--duration-days", min=1, max=365)] = 90, + memory_enabled: Annotated[bool, typer.Option("--memory/--no-memory")] = False, + memory_mode: Annotated[MemoryMode | None, typer.Option("--memory-mode", case_sensitive=False)] = None, + models: Annotated[ + list[str] | None, + typer.Option("--model", "-m", help="Allowed model (repeatable). Empty = all."), + ] = None, + default_model: Annotated[str | None, typer.Option("--default-model")] = None, + output: OutputOpt = OutputFmt.JSON, +) -> None: + """Provision a new LiteLLM virtual key.""" + with _build_client(token, base_url) as client: + resp = client.keys.provision( + key_alias=alias, + duration_days=duration_days, + memory_enabled=memory_enabled, + memory_mode=memory_mode, + models=models, + default_model=default_model, + ) + # SecretStr renders as "**********" in dump unless we unwrap. + payload = resp.model_dump(mode="json") + payload["api_key"] = resp.api_key.get_secret_value() + if output is OutputFmt.TABLE: + table = Table(title="Provisioned key") + for label, value in ( + ("api_key", payload["api_key"]), + ("key_name", payload["key_name"]), + ("user_id", payload["user_id"]), + ("expires", payload.get("expires") or "—"), + ): + table.add_row(label, str(value)) + stdout.print(table) + return + stdout.print_json(json.dumps(payload, default=str)) + + +@keys_app.command("revoke") +@_handle +def keys_revoke( + token: TokenOpt = None, + base_url: BaseUrlOpt = None, + key: Annotated[str, typer.Option("--key", help="sk-... value or alias.")] = ..., # type: ignore[assignment] + output: OutputOpt = OutputFmt.JSON, +) -> None: + """Revoke a key by sk-... value or alias.""" + with _build_client(token, base_url) as client: + resp = client.keys.revoke(key=key) + _render(resp, output) + + +@keys_app.command("get-config") +@_handle +def keys_get_config( + token: TokenOpt = None, + base_url: BaseUrlOpt = None, + key: Annotated[str, typer.Option("--key")] = ..., # type: ignore[assignment] + output: OutputOpt = OutputFmt.JSON, +) -> None: + """Print the model config for one key.""" + with _build_client(token, base_url) as client: + resp = client.keys.get_config(key=key) + _render(resp, output) + + +@keys_app.command("update-model") +@_handle +def keys_update_model( + token: TokenOpt = None, + base_url: BaseUrlOpt = None, + key: Annotated[str, typer.Option("--key")] = ..., # type: ignore[assignment] + models: Annotated[list[str], typer.Option("--model", "-m", help="Allowed model (repeatable).")] = ..., # type: ignore[assignment] + default_model: Annotated[str | None, typer.Option("--default-model")] = None, + output: OutputOpt = OutputFmt.JSON, +) -> None: + """Update model config for a key.""" + with _build_client(token, base_url) as client: + resp = client.keys.update_model( + key=key, + models=models, + default_model=default_model, + ) + _render(resp, output) + + +# --------------------------------------------------------------------------- +# models subcommands +# --------------------------------------------------------------------------- + + +@models_app.command("list") +@_handle +def models_list( + token: TokenOpt = None, + base_url: BaseUrlOpt = None, + output: OutputOpt = OutputFmt.TABLE, +) -> None: + """List models available on the platform.""" + with _build_client(token, base_url) as client: + resp = client.models.list() + if output is OutputFmt.TABLE: + table = Table(title="Available models") + table.add_column("Model") + for m in resp.models: + table.add_row(m.model_name) + _render(resp, output, table=table) + else: + _render(resp, output) + + +# --------------------------------------------------------------------------- +# misc +# --------------------------------------------------------------------------- + + +@app.command("version") +def version() -> None: + """Print the askii client version.""" + stdout.print(__version__) + + +def main() -> None: + """Console-script entry point.""" + try: + app() + except SystemExit: + raise + except Exception as exc: # pragma: no cover — defensive last resort + stderr.print(f"[red]Unexpected error:[/red] {exc}") + sys.exit(EXIT_UNEXPECTED) + + +if __name__ == "__main__": # pragma: no cover + main() + + +__all__ = ["app", "main", "OutputFmt"] diff --git a/src/askii/models/__init__.py b/src/askii/models/__init__.py new file mode 100644 index 0000000..08db4c5 --- /dev/null +++ b/src/askii/models/__init__.py @@ -0,0 +1,34 @@ +"""Public Pydantic models for the askii client.""" + +from askii.models._base import AskiiModel +from askii.models._shared import MemoryMode +from askii.models.keys import ( + GetKeyConfigRequest, + KeyConfig, + KeyInfo, + ListKeysResponse, + ProvisionKeyRequest, + ProvisionKeyResponse, + RevokeKeyRequest, + RevokeKeyResponse, + UpdateKeyModelRequest, + UpdateKeyModelResponse, +) +from askii.models.models import AvailableModel, AvailableModelsResponse + +__all__ = [ + "AskiiModel", + "MemoryMode", + "ProvisionKeyRequest", + "ProvisionKeyResponse", + "KeyInfo", + "ListKeysResponse", + "RevokeKeyRequest", + "RevokeKeyResponse", + "GetKeyConfigRequest", + "KeyConfig", + "UpdateKeyModelRequest", + "UpdateKeyModelResponse", + "AvailableModel", + "AvailableModelsResponse", +] diff --git a/src/askii/models/_base.py b/src/askii/models/_base.py new file mode 100644 index 0000000..a833442 --- /dev/null +++ b/src/askii/models/_base.py @@ -0,0 +1,27 @@ +"""Shared base model for all askii Pydantic types.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class AskiiModel(BaseModel): + """Base for every request/response model in the askii client. + + ``extra="forbid"`` ensures CI flags schema drift the moment the upstream + starts returning a field we haven't modeled. ``populate_by_name`` keeps + forward-compatible renames cheap. + """ + + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + str_strip_whitespace=True, + ser_json_timedelta="iso8601", + ser_json_bytes="base64", + validate_assignment=False, + protected_namespaces=(), + ) + + +__all__ = ["AskiiModel"] diff --git a/src/askii/models/_shared.py b/src/askii/models/_shared.py new file mode 100644 index 0000000..2efbdaa --- /dev/null +++ b/src/askii/models/_shared.py @@ -0,0 +1,16 @@ +"""Enums and small value types shared across resource models.""" + +from __future__ import annotations + +from enum import Enum + + +class MemoryMode(str, Enum): + """Backing memory engine for an Askii virtual key.""" + + PKG = "pkg" + CKG = "ckg" + ALL = "all" + + +__all__ = ["MemoryMode"] diff --git a/src/askii/models/keys.py b/src/askii/models/keys.py new file mode 100644 index 0000000..f6ce21a --- /dev/null +++ b/src/askii/models/keys.py @@ -0,0 +1,119 @@ +"""Pydantic models for the Platform Key Management endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Annotated + +from pydantic import Field, SecretStr + +from askii.models._base import AskiiModel +from askii.models._shared import MemoryMode + + +class ProvisionKeyRequest(AskiiModel): + """Body for ``POST /platform/provision-key`` (sans ``mpass_token``).""" + + key_alias: str | None = None + duration_days: Annotated[int, Field(ge=1, le=365)] = 90 + memory_enabled: bool = False + memory_mode: MemoryMode | None = None + models: list[str] | None = None + default_model: str | None = None + + +class ProvisionKeyResponse(AskiiModel): + """Response from ``POST /platform/provision-key``. + + Note ``api_key`` is wrapped in :class:`pydantic.SecretStr` — call + ``.get_secret_value()`` to read the raw ``sk-...`` value, and avoid logging + or printing the unwrapped form. + """ + + api_key: SecretStr + key_name: str + user_id: str + expires: datetime | None = None + + +class KeyInfo(AskiiModel): + """One element of :class:`ListKeysResponse.keys`.""" + + key_name: str + key_alias: str | None = None + created_at: datetime | None = None + expires: datetime | None = None + spend: float + blocked: bool | None = None + models: list[str] = Field(default_factory=list) + default_model: str | None = None + memory_enabled: bool = False + memory_mode: MemoryMode | None = None + + +class ListKeysResponse(AskiiModel): + """Response from ``POST /platform/list-keys``.""" + + user_id: str + keys: list[KeyInfo] = Field(default_factory=list) + + +class RevokeKeyRequest(AskiiModel): + """Body for ``POST /platform/revoke-key`` (sans ``mpass_token``).""" + + key: str + + +class RevokeKeyResponse(AskiiModel): + """Response from ``POST /platform/revoke-key``.""" + + revoked: bool + detail: str + + +class GetKeyConfigRequest(AskiiModel): + """Body for ``POST /platform/get-key-config`` (sans ``mpass_token``).""" + + key: str + + +class KeyConfig(AskiiModel): + """Response from ``POST /platform/get-key-config``.""" + + key_name: str + key_alias: str | None = None + models: list[str] = Field(default_factory=list) + default_model: str | None = None + memory_enabled: bool + memory_mode: MemoryMode | None = None + + +class UpdateKeyModelRequest(AskiiModel): + """Body for ``POST /platform/update-key-model`` (sans ``mpass_token``).""" + + key: str + models: list[str] + default_model: str | None = None + + +class UpdateKeyModelResponse(AskiiModel): + """Response from ``POST /platform/update-key-model``.""" + + updated: bool + key_name: str + models: list[str] = Field(default_factory=list) + default_model: str | None = None + + +__all__ = [ + "ProvisionKeyRequest", + "ProvisionKeyResponse", + "KeyInfo", + "ListKeysResponse", + "RevokeKeyRequest", + "RevokeKeyResponse", + "GetKeyConfigRequest", + "KeyConfig", + "UpdateKeyModelRequest", + "UpdateKeyModelResponse", +] diff --git a/src/askii/models/models.py b/src/askii/models/models.py new file mode 100644 index 0000000..4e48c61 --- /dev/null +++ b/src/askii/models/models.py @@ -0,0 +1,22 @@ +"""Pydantic models for the ``available-models`` endpoint.""" + +from __future__ import annotations + +from pydantic import Field + +from askii.models._base import AskiiModel + + +class AvailableModel(AskiiModel): + """One model exposed by ``POST /platform/available-models``.""" + + model_name: str + + +class AvailableModelsResponse(AskiiModel): + """Response from ``POST /platform/available-models``.""" + + models: list[AvailableModel] = Field(default_factory=list) + + +__all__ = ["AvailableModel", "AvailableModelsResponse"] diff --git a/src/askii/py.typed b/src/askii/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/askii/resources/__init__.py b/src/askii/resources/__init__.py new file mode 100644 index 0000000..728b8f8 --- /dev/null +++ b/src/askii/resources/__init__.py @@ -0,0 +1,11 @@ +"""Resource namespaces — the high-level surface of the askii client.""" + +from askii.resources.keys import AsyncKeysResource, KeysResource +from askii.resources.models import AsyncModelsResource, ModelsResource + +__all__ = [ + "AsyncKeysResource", + "KeysResource", + "AsyncModelsResource", + "ModelsResource", +] diff --git a/src/askii/resources/_base.py b/src/askii/resources/_base.py new file mode 100644 index 0000000..4693839 --- /dev/null +++ b/src/askii/resources/_base.py @@ -0,0 +1,30 @@ +"""Resource base classes — wire a resource to its parent client.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from askii._client.async_client import AsyncAskii + from askii._client.sync_client import Askii + + +AsyncClientT = TypeVar("AsyncClientT", bound="AsyncAskii") +SyncClientT = TypeVar("SyncClientT", bound="Askii") + + +class _AsyncResource(Generic[AsyncClientT]): + """Holds a reference to the parent async client. Subclasses add methods.""" + + def __init__(self, client: AsyncClientT) -> None: + self._client = client + + +class _SyncResource(Generic[SyncClientT]): + """Holds a reference to the parent sync client. Subclasses add methods.""" + + def __init__(self, client: SyncClientT) -> None: + self._client = client + + +__all__ = ["_AsyncResource", "_SyncResource"] diff --git a/src/askii/resources/keys.py b/src/askii/resources/keys.py new file mode 100644 index 0000000..a72fac3 --- /dev/null +++ b/src/askii/resources/keys.py @@ -0,0 +1,200 @@ +"""``client.keys.*`` — Platform Key Management resource.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from askii.models import ( + GetKeyConfigRequest, + KeyConfig, + ListKeysResponse, + ProvisionKeyRequest, + ProvisionKeyResponse, + RevokeKeyRequest, + RevokeKeyResponse, + UpdateKeyModelRequest, + UpdateKeyModelResponse, +) +from askii.models._shared import MemoryMode +from askii.resources._base import _AsyncResource, _SyncResource + +if TYPE_CHECKING: + from askii._client.async_client import AsyncAskii # noqa: F401 — used in string forward ref + from askii._client.sync_client import Askii # noqa: F401 — used in string forward ref + + +_RESOURCE = "keys" + +# Alias avoids `list[str]` resolving to the class's `list()` method in annotations. +_StrList = list[str] + + +def _provision_body(req: ProvisionKeyRequest) -> dict[str, object]: + return req.model_dump(exclude_none=True, mode="json") + + +def _revoke_body(key: str) -> dict[str, object]: + return RevokeKeyRequest(key=key).model_dump(mode="json") + + +def _get_config_body(key: str) -> dict[str, object]: + return GetKeyConfigRequest(key=key).model_dump(mode="json") + + +def _update_body(key: str, models: _StrList, default_model: str | None) -> dict[str, object]: + return UpdateKeyModelRequest( + key=key, + models=models, + default_model=default_model, + ).model_dump(mode="json", exclude_none=True) + + +class AsyncKeysResource(_AsyncResource["AsyncAskii"]): + """Async ``keys`` namespace.""" + + async def provision( + self, + *, + key_alias: str | None = None, + duration_days: int = 90, + memory_enabled: bool = False, + memory_mode: MemoryMode | None = None, + models: _StrList | None = None, + default_model: str | None = None, + ) -> ProvisionKeyResponse: + """Provision a new LiteLLM virtual key.""" + req = ProvisionKeyRequest( + key_alias=key_alias, + duration_days=duration_days, + memory_enabled=memory_enabled, + memory_mode=memory_mode, + models=models, + default_model=default_model, + ) + data = await self._client._arequest("POST", "/platform/provision-key", body=_provision_body(req)) + await self._client._ainvalidate(_RESOURCE) + return ProvisionKeyResponse.model_validate(data) + + async def list(self, *, cache_ttl: float | None = None) -> ListKeysResponse: + """List active keys for the authenticated user.""" + data = await self._client._arequest( + "POST", + "/platform/list-keys", + body={}, + cache_resource=_RESOURCE, + cache_op="list", + cache_ttl=cache_ttl, + ) + return ListKeysResponse.model_validate(data) + + async def revoke(self, *, key: str) -> RevokeKeyResponse: + """Revoke a key by ``sk-...`` value or ``key_alias``.""" + data = await self._client._arequest("POST", "/platform/revoke-key", body=_revoke_body(key)) + await self._client._ainvalidate(_RESOURCE) + return RevokeKeyResponse.model_validate(data) + + async def get_config(self, *, key: str, cache_ttl: float | None = None) -> KeyConfig: + """Return model configuration for one key.""" + data = await self._client._arequest( + "POST", + "/platform/get-key-config", + body=_get_config_body(key), + cache_resource=_RESOURCE, + cache_op="get_config", + cache_args={"key": key}, + cache_ttl=cache_ttl, + ) + return KeyConfig.model_validate(data) + + async def update_model( + self, + *, + key: str, + models: _StrList, + default_model: str | None = None, + ) -> UpdateKeyModelResponse: + """Update the model configuration for a key.""" + data = await self._client._arequest( + "POST", + "/platform/update-key-model", + body=_update_body(key, models, default_model), + ) + await self._client._ainvalidate(_RESOURCE) + return UpdateKeyModelResponse.model_validate(data) + + +class KeysResource(_SyncResource["Askii"]): + """Sync ``keys`` namespace.""" + + def provision( + self, + *, + key_alias: str | None = None, + duration_days: int = 90, + memory_enabled: bool = False, + memory_mode: MemoryMode | None = None, + models: _StrList | None = None, + default_model: str | None = None, + ) -> ProvisionKeyResponse: + """Provision a new LiteLLM virtual key.""" + req = ProvisionKeyRequest( + key_alias=key_alias, + duration_days=duration_days, + memory_enabled=memory_enabled, + memory_mode=memory_mode, + models=models, + default_model=default_model, + ) + data = self._client._request("POST", "/platform/provision-key", body=_provision_body(req)) + self._client._invalidate(_RESOURCE) + return ProvisionKeyResponse.model_validate(data) + + def list(self, *, cache_ttl: float | None = None) -> ListKeysResponse: + """List active keys for the authenticated user.""" + data = self._client._request( + "POST", + "/platform/list-keys", + body={}, + cache_resource=_RESOURCE, + cache_op="list", + cache_ttl=cache_ttl, + ) + return ListKeysResponse.model_validate(data) + + def revoke(self, *, key: str) -> RevokeKeyResponse: + """Revoke a key by ``sk-...`` value or ``key_alias``.""" + data = self._client._request("POST", "/platform/revoke-key", body=_revoke_body(key)) + self._client._invalidate(_RESOURCE) + return RevokeKeyResponse.model_validate(data) + + def get_config(self, *, key: str, cache_ttl: float | None = None) -> KeyConfig: + """Return model configuration for one key.""" + data = self._client._request( + "POST", + "/platform/get-key-config", + body=_get_config_body(key), + cache_resource=_RESOURCE, + cache_op="get_config", + cache_args={"key": key}, + cache_ttl=cache_ttl, + ) + return KeyConfig.model_validate(data) + + def update_model( + self, + *, + key: str, + models: _StrList, + default_model: str | None = None, + ) -> UpdateKeyModelResponse: + """Update the model configuration for a key.""" + data = self._client._request( + "POST", + "/platform/update-key-model", + body=_update_body(key, models, default_model), + ) + self._client._invalidate(_RESOURCE) + return UpdateKeyModelResponse.model_validate(data) + + +__all__ = ["AsyncKeysResource", "KeysResource"] diff --git a/src/askii/resources/models.py b/src/askii/resources/models.py new file mode 100644 index 0000000..df0f8e5 --- /dev/null +++ b/src/askii/resources/models.py @@ -0,0 +1,50 @@ +"""``client.models.*`` — available-models lookup.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from askii.models import AvailableModelsResponse +from askii.resources._base import _AsyncResource, _SyncResource + +if TYPE_CHECKING: + from askii._client.async_client import AsyncAskii # noqa: F401 — used in string forward ref + from askii._client.sync_client import Askii # noqa: F401 — used in string forward ref + + +_RESOURCE = "models" + + +class AsyncModelsResource(_AsyncResource["AsyncAskii"]): + """Async ``models`` namespace.""" + + async def list(self, *, cache_ttl: float | None = None) -> AvailableModelsResponse: + """List the LLM models available on the platform.""" + data = await self._client._arequest( + "POST", + "/platform/available-models", + body={}, + cache_resource=_RESOURCE, + cache_op="list", + cache_ttl=cache_ttl, + ) + return AvailableModelsResponse.model_validate(data) + + +class ModelsResource(_SyncResource["Askii"]): + """Sync ``models`` namespace.""" + + def list(self, *, cache_ttl: float | None = None) -> AvailableModelsResponse: + """List the LLM models available on the platform.""" + data = self._client._request( + "POST", + "/platform/available-models", + body={}, + cache_resource=_RESOURCE, + cache_op="list", + cache_ttl=cache_ttl, + ) + return AvailableModelsResponse.model_validate(data) + + +__all__ = ["AsyncModelsResource", "ModelsResource"] 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..dcc9323 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,161 @@ +"""Shared pytest fixtures for the askii test suite.""" + +from __future__ import annotations + +import json +import os +from collections.abc import Callable, Iterable, Iterator +from typing import Any + +import httpx +import pytest + +from askii import AskiiConfig + +ASKII_ENV_VARS = ("ASKII_TOKEN", "ASKII_BASE_URL", "ASKII_TIMEOUT_SECONDS", "ASKII_MAX_RETRIES") + + +@pytest.fixture(autouse=True) +def _scrub_askii_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Wipe ASKII_* env vars so tests run from a known baseline.""" + for name in ASKII_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def make_response() -> Callable[..., httpx.Response]: + """Build an ``httpx.Response`` for the mock transport.""" + + def _factory( + status: int = 200, + body: Any | None = None, + headers: dict[str, str] | None = None, + ) -> httpx.Response: + request = httpx.Request("POST", "https://api.askii.ai/_test") + content = b"" + merged_headers = {"content-type": "application/json", **(headers or {})} + if body is not None: + content = json.dumps(body).encode("utf-8") + return httpx.Response(status, headers=merged_headers, content=content, request=request) + + return _factory + + +@pytest.fixture +def mock_transport() -> Callable[..., tuple[httpx.MockTransport, list[httpx.Request]]]: + """Build an ``httpx.MockTransport`` that yields a deterministic response sequence. + + Pass either: + - A single ``httpx.Response`` (replays it for every request) + - An iterable of responses (one per request, in order) + - A handler callable ``(httpx.Request) -> httpx.Response`` + """ + + def _factory( + responses: httpx.Response | Iterable[httpx.Response] | Callable[[httpx.Request], httpx.Response], + ) -> tuple[httpx.MockTransport, list[httpx.Request]]: + recorded: list[httpx.Request] = [] + + if callable(responses) and not isinstance(responses, httpx.Response): + handler = responses # type: ignore[assignment] + + def _wrap(request: httpx.Request) -> httpx.Response: + recorded.append(request) + return handler(request) + + return httpx.MockTransport(_wrap), recorded + + if isinstance(responses, httpx.Response): + single = responses + + def _replay(request: httpx.Request) -> httpx.Response: + recorded.append(request) + return single + + return httpx.MockTransport(_replay), recorded + + iterator = iter(responses) + + def _seq(request: httpx.Request) -> httpx.Response: + recorded.append(request) + return next(iterator) + + return httpx.MockTransport(_seq), recorded + + return _factory + + +@pytest.fixture +def config_factory() -> Callable[..., AskiiConfig]: + """Convenience factory for AskiiConfig with sane test defaults.""" + + def _factory(**overrides: Any) -> AskiiConfig: + return AskiiConfig( + base_url=overrides.pop("base_url", "https://api.askii.test"), + timeout=overrides.pop("timeout", 5.0), + max_retries=overrides.pop("max_retries", 1), + http2=overrides.pop("http2", False), + token=overrides.pop("token", "test-token"), + **overrides, + ) + + return _factory + + +@pytest.fixture +def fixed_clock(monkeypatch: pytest.MonkeyPatch) -> Iterator[Callable[[float], None]]: + """Override ``time.monotonic`` inside ``askii._cache.memory`` for deterministic TTL tests.""" + current = [0.0] + + def _now() -> float: + return current[0] + + monkeypatch.setattr("askii._cache.memory.time.monotonic", _now) + + def _advance(seconds: float) -> None: + current[0] += seconds + + yield _advance + + +@pytest.fixture(autouse=True) +def _reset_correlation_id() -> Iterator[None]: + """Ensure each test starts without a bound correlation ID.""" + from askii._logging import _correlation_id_var + + token = _correlation_id_var.set(None) + try: + yield + finally: + _correlation_id_var.reset(token) + + +_FAKEREDIS_AVAILABLE = False +try: # noqa: SIM105 + import fakeredis # noqa: F401 + + _FAKEREDIS_AVAILABLE = True +except ImportError: + pass + + +@pytest.fixture +def fakeredis_clients() -> Any: + """Yield (sync, async) fakeredis clients, or skip if fakeredis is missing.""" + if not _FAKEREDIS_AVAILABLE: + pytest.skip("fakeredis not installed") + import fakeredis + import fakeredis.aioredis + + server = fakeredis.FakeServer() + sync = fakeredis.FakeRedis(server=server) + aio = fakeredis.aioredis.FakeRedis(server=server) + yield sync, aio + + +@pytest.fixture +def restore_cwd() -> Iterator[str]: + """Restore the working directory after a test that runs CLI commands.""" + here = os.getcwd() + yield here + os.chdir(here) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_cache_memory.py b/tests/unit/test_cache_memory.py new file mode 100644 index 0000000..9fd1e81 --- /dev/null +++ b/tests/unit/test_cache_memory.py @@ -0,0 +1,140 @@ +"""Tests for the in-memory TTL + LRU cache.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from askii import InMemoryCache, build_cache_key, build_resource_prefix + +# --- sync path ------------------------------------------------------- + + +def test_get_missing_returns_none() -> None: + cache = InMemoryCache(default_ttl=10) + assert cache.get("missing") is None + + +def test_set_get_roundtrip() -> None: + cache = InMemoryCache(default_ttl=10) + cache.set("k", {"v": 1}, ttl=10) + assert cache.get("k") == {"v": 1} + + +def test_ttl_zero_is_no_op(fixed_clock: Callable[[float], None]) -> None: + cache = InMemoryCache(default_ttl=0) + cache.set("k", "v", ttl=0) + assert cache.get("k") is None + + +def test_ttl_expiry(fixed_clock: Callable[[float], None]) -> None: + cache = InMemoryCache(default_ttl=0) + cache.set("k", "v", ttl=5) + fixed_clock(4.9) + assert cache.get("k") == "v" + fixed_clock(0.2) + assert cache.get("k") is None + + +def test_lru_eviction() -> None: + cache = InMemoryCache(maxsize=3, default_ttl=10) + cache.set("a", 1, ttl=10) + cache.set("b", 2, ttl=10) + cache.set("c", 3, ttl=10) + # Touch "a" so "b" becomes the LRU victim. + assert cache.get("a") == 1 + cache.set("d", 4, ttl=10) + assert cache.get("b") is None + assert cache.get("a") == 1 + + +def test_delete() -> None: + cache = InMemoryCache(default_ttl=10) + cache.set("k", "v", ttl=10) + cache.delete("k") + assert cache.get("k") is None + + +def test_delete_prefix_removes_matching_keys() -> None: + cache = InMemoryCache(default_ttl=10) + cache.set("askii:v1:abc:keys:list", "x", ttl=10) + cache.set("askii:v1:abc:keys:get_config", "y", ttl=10) + cache.set("askii:v1:abc:models:list", "z", ttl=10) + cache.delete_prefix("askii:v1:abc:keys") + assert cache.get("askii:v1:abc:keys:list") is None + assert cache.get("askii:v1:abc:keys:get_config") is None + assert cache.get("askii:v1:abc:models:list") == "z" + + +def test_clear() -> None: + cache = InMemoryCache(default_ttl=10) + cache.set("a", 1, ttl=10) + cache.set("b", 2, ttl=10) + cache.clear() + assert cache.get("a") is None + assert cache.get("b") is None + + +def test_maxsize_zero_rejected() -> None: + with pytest.raises(ValueError): + InMemoryCache(maxsize=0) + + +# --- async path ------------------------------------------------------ + + +async def test_aget_aset_roundtrip() -> None: + cache = InMemoryCache(default_ttl=10) + await cache.aset("k", "v", ttl=10) + assert await cache.aget("k") == "v" + + +async def test_aset_ttl_zero_is_no_op() -> None: + cache = InMemoryCache(default_ttl=0) + await cache.aset("k", "v", ttl=0) + assert await cache.aget("k") is None + + +async def test_async_delete_prefix() -> None: + cache = InMemoryCache(default_ttl=10) + await cache.aset("askii:v1:u:keys:list", "x", ttl=10) + await cache.aset("askii:v1:u:keys:get", "y", ttl=10) + await cache.adelete_prefix("askii:v1:u:keys") + assert await cache.aget("askii:v1:u:keys:list") is None + assert await cache.aget("askii:v1:u:keys:get") is None + + +async def test_async_clear() -> None: + cache = InMemoryCache(default_ttl=10) + await cache.aset("a", 1, ttl=10) + await cache.aclear() + assert await cache.aget("a") is None + + +# --- key builders ---------------------------------------------------- + + +def test_build_cache_key_is_deterministic() -> None: + a = build_cache_key("tok", "keys", "list", None) + b = build_cache_key("tok", "keys", "list", None) + assert a == b + + +def test_build_cache_key_differs_by_args() -> None: + a = build_cache_key("tok", "keys", "get_config", {"key": "alias-a"}) + b = build_cache_key("tok", "keys", "get_config", {"key": "alias-b"}) + assert a != b + + +def test_build_cache_key_does_not_leak_token() -> None: + key = build_cache_key("super-secret-jwt", "keys", "list", None) + assert "super-secret-jwt" not in key + assert key.startswith("askii:v1:") + + +def test_build_resource_prefix_is_per_user() -> None: + a = build_resource_prefix("user-a", "keys") + b = build_resource_prefix("user-b", "keys") + assert a != b + assert a.endswith(":keys") diff --git a/tests/unit/test_cache_redis.py b/tests/unit/test_cache_redis.py new file mode 100644 index 0000000..e21f951 --- /dev/null +++ b/tests/unit/test_cache_redis.py @@ -0,0 +1,106 @@ +"""Tests for the Redis cache backend (skipped unless fakeredis is installed).""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + + +def test_redis_cache_requires_extra(monkeypatch: pytest.MonkeyPatch) -> None: + """RedisCache raises a helpful error if the redis dependency is absent. + + We simulate the missing extra by stashing real ``redis`` then restoring it. + """ + import importlib + import sys + + # Force fresh import; pretend redis is missing. + monkeypatch.setitem(sys.modules, "redis", None) + sys.modules.pop("askii._cache.redis", None) + try: + with pytest.raises((RuntimeError, ImportError)): + from askii._cache.redis import RedisCache # noqa: F401 + + RedisCache(url="redis://localhost:6379/0") + finally: + monkeypatch.delitem(sys.modules, "redis", raising=False) + importlib.invalidate_caches() + + +@pytest.fixture +def redis_cache(fakeredis_clients: tuple[Any, Any]) -> Iterator[Any]: + from askii._cache.redis import RedisCache + + sync, aio = fakeredis_clients + yield RedisCache(namespace="askii", sync_client=sync, async_client=aio) + + +def test_redis_set_get_roundtrip_sync(redis_cache: Any) -> None: + redis_cache.set("askii:v1:u:keys:list", {"a": 1}, ttl=30) + assert redis_cache.get("askii:v1:u:keys:list") == {"a": 1} + + +def test_redis_get_missing_returns_none(redis_cache: Any) -> None: + assert redis_cache.get("nope") is None + + +def test_redis_set_ttl_zero_is_no_op(redis_cache: Any) -> None: + redis_cache.set("k", "v", ttl=0) + assert redis_cache.get("k") is None + + +def test_redis_delete(redis_cache: Any) -> None: + redis_cache.set("k", "v", ttl=30) + redis_cache.delete("k") + assert redis_cache.get("k") is None + + +def test_redis_delete_prefix_removes_matching(redis_cache: Any) -> None: + redis_cache.set("askii:v1:u:keys:list", "a", ttl=30) + redis_cache.set("askii:v1:u:keys:get", "b", ttl=30) + redis_cache.set("askii:v1:u:models:list", "c", ttl=30) + redis_cache.delete_prefix("askii:v1:u:keys") + assert redis_cache.get("askii:v1:u:keys:list") is None + assert redis_cache.get("askii:v1:u:keys:get") is None + assert redis_cache.get("askii:v1:u:models:list") == "c" + + +def test_redis_clear_requires_namespace(fakeredis_clients: tuple[Any, Any]) -> None: + from askii._cache.redis import RedisCache + + sync, aio = fakeredis_clients + cache = RedisCache(sync_client=sync, async_client=aio) + with pytest.raises(RuntimeError): + cache.clear() + + +async def test_redis_async_roundtrip(redis_cache: Any) -> None: + await redis_cache.aset("k", {"a": 1}, ttl=30) + assert await redis_cache.aget("k") == {"a": 1} + + +async def test_redis_async_delete_prefix(redis_cache: Any) -> None: + await redis_cache.aset("askii:v1:u:keys:list", "x", ttl=30) + await redis_cache.aset("askii:v1:u:models:list", "y", ttl=30) + await redis_cache.adelete_prefix("askii:v1:u:keys") + assert await redis_cache.aget("askii:v1:u:keys:list") is None + assert await redis_cache.aget("askii:v1:u:models:list") == "y" + + +async def test_redis_async_clear_requires_namespace(fakeredis_clients: tuple[Any, Any]) -> None: + from askii._cache.redis import RedisCache + + sync, aio = fakeredis_clients + cache = RedisCache(sync_client=sync, async_client=aio) + with pytest.raises(RuntimeError): + await cache.aclear() + + +def test_redis_cache_requires_some_arg() -> None: + pytest.importorskip("fakeredis") + from askii._cache.redis import RedisCache + + with pytest.raises(ValueError): + RedisCache() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..4327cc0 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,194 @@ +"""CLI tests via Typer's ``CliRunner``.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from askii import AskiiAuthError, AskiiServerError +from askii.cli._app import EXIT_AUTH, EXIT_SERVER, app + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def mock_client_factory() -> Iterator[MagicMock]: + """Patch ``_build_client`` so commands don't try to open real httpx clients.""" + with patch("askii.cli._app._build_client") as factory: + instance = MagicMock() + instance.__enter__.return_value = instance + instance.__exit__.return_value = None + factory.return_value = instance + yield instance + + +def test_version_command(runner: CliRunner) -> None: + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert result.stdout.strip() + + +def test_missing_token_exits_with_auth_code(runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ASKII_TOKEN", raising=False) + result = runner.invoke(app, ["keys", "list"]) + assert result.exit_code == EXIT_AUTH + + +def test_keys_list_json(runner: CliRunner, mock_client_factory: MagicMock) -> None: + from askii import KeyInfo, ListKeysResponse + + mock_client_factory.keys.list.return_value = ListKeysResponse( + user_id="u-1", + keys=[KeyInfo(key_name="k1", key_alias="a", spend=0.0, models=["gpt-4o"], memory_enabled=False)], + ) + result = runner.invoke( + app, + ["keys", "list", "--token", "jwt-1", "--output", "json"], + ) + assert result.exit_code == 0, result.stderr + body: Any = json.loads(result.stdout) + assert body["user_id"] == "u-1" + assert body["keys"][0]["key_name"] == "k1" + + +def test_keys_list_table(runner: CliRunner, mock_client_factory: MagicMock) -> None: + from askii import KeyInfo, ListKeysResponse + + mock_client_factory.keys.list.return_value = ListKeysResponse( + user_id="u-2", + keys=[KeyInfo(key_name="kn", spend=1.0, models=[], memory_enabled=False)], + ) + result = runner.invoke(app, ["keys", "list", "--token", "jwt-1"]) + assert result.exit_code == 0, result.stderr + assert "u-2" in result.stdout + assert "kn" in result.stdout + + +def test_keys_provision_unwraps_secret(runner: CliRunner, mock_client_factory: MagicMock) -> None: + from askii import ProvisionKeyResponse + + mock_client_factory.keys.provision.return_value = ProvisionKeyResponse( + api_key="sk-realrealreal12345", + key_name="n", + user_id="u", + ) + result = runner.invoke( + app, + ["keys", "provision", "--token", "jwt-1", "--alias", "foo", "--duration-days", "30"], + ) + assert result.exit_code == 0, result.stderr + body = json.loads(result.stdout) + assert body["api_key"] == "sk-realrealreal12345" + + +def test_keys_revoke_returns_json(runner: CliRunner, mock_client_factory: MagicMock) -> None: + from askii import RevokeKeyResponse + + mock_client_factory.keys.revoke.return_value = RevokeKeyResponse(revoked=True, detail="ok") + result = runner.invoke(app, ["keys", "revoke", "--token", "jwt-1", "--key", "alias"]) + assert result.exit_code == 0, result.stderr + body = json.loads(result.stdout) + assert body == {"revoked": True, "detail": "ok"} + + +def test_keys_get_config(runner: CliRunner, mock_client_factory: MagicMock) -> None: + from askii import KeyConfig + + mock_client_factory.keys.get_config.return_value = KeyConfig(key_name="n", models=[], memory_enabled=False) + result = runner.invoke(app, ["keys", "get-config", "--token", "jwt-1", "--key", "alias"]) + assert result.exit_code == 0 + + +def test_keys_update_model(runner: CliRunner, mock_client_factory: MagicMock) -> None: + from askii import UpdateKeyModelResponse + + mock_client_factory.keys.update_model.return_value = UpdateKeyModelResponse( + updated=True, key_name="n", models=["gpt-4o"], default_model="gpt-4o" + ) + result = runner.invoke( + app, + [ + "keys", + "update-model", + "--token", + "jwt-1", + "--key", + "alias", + "-m", + "gpt-4o", + "--default-model", + "gpt-4o", + ], + ) + assert result.exit_code == 0 + + +def test_models_list_json(runner: CliRunner, mock_client_factory: MagicMock) -> None: + from askii import AvailableModel, AvailableModelsResponse + + mock_client_factory.models.list.return_value = AvailableModelsResponse(models=[AvailableModel(model_name="gpt-4o")]) + result = runner.invoke(app, ["models", "list", "--token", "jwt-1", "--output", "json"]) + assert result.exit_code == 0 + body = json.loads(result.stdout) + assert body["models"][0]["model_name"] == "gpt-4o" + + +def test_auth_error_exit_code(runner: CliRunner, mock_client_factory: MagicMock) -> None: + mock_client_factory.keys.list.side_effect = AskiiAuthError(401, "bad jwt") + result = runner.invoke(app, ["keys", "list", "--token", "jwt-1"]) + assert result.exit_code == EXIT_AUTH + + +def test_server_error_exit_code(runner: CliRunner, mock_client_factory: MagicMock) -> None: + mock_client_factory.keys.list.side_effect = AskiiServerError(500, "boom") + result = runner.invoke(app, ["keys", "list", "--token", "jwt-1"]) + assert result.exit_code == EXIT_SERVER + + +def test_base_url_passes_through_to_config(runner: CliRunner) -> None: + """--base-url should reach AskiiConfig (caught here via patching).""" + seen: dict[str, Any] = {} + + def fake_from_env(**kwargs: Any) -> Any: + seen.update(kwargs) + from askii._cache.memory import InMemoryCache + from askii._config import AskiiConfig + + return AskiiConfig( + base_url=kwargs.get("base_url", "https://api.askii.test"), + token=kwargs.get("token", "jwt"), + cache=InMemoryCache(), + ) + + with ( + patch("askii.cli._app.AskiiConfig.from_env", side_effect=fake_from_env), + patch("askii.cli._app.Askii") as askii_cls, + ): + instance = MagicMock() + instance.__enter__.return_value = instance + instance.__exit__.return_value = None + instance.models.list.return_value = MagicMock( + models=[], + model_dump=lambda mode="json": {"models": []}, + ) + askii_cls.return_value = instance + result = runner.invoke( + app, + ["models", "list", "--token", "jwt-1", "--base-url", "https://custom.test", "--output", "json"], + ) + assert result.exit_code == 0, result.stderr + assert seen["base_url"] == "https://custom.test" + + +def test_cli_help(runner: CliRunner) -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Askii platform CLI" in result.stdout diff --git a/tests/unit/test_clients.py b/tests/unit/test_clients.py new file mode 100644 index 0000000..b4bdbeb --- /dev/null +++ b/tests/unit/test_clients.py @@ -0,0 +1,278 @@ +"""Resource-level tests for AsyncAskii / Askii.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +import httpx +import pytest + +from askii import ( + Askii, + AskiiConfig, + AsyncAskii, + InMemoryCache, + MemoryMode, +) + + +def _resp(status: int = 200, body: dict[str, Any] | None = None) -> httpx.Response: + content = json.dumps(body or {}).encode() + request = httpx.Request("POST", "https://api.askii.test/x") + return httpx.Response(status, headers={"content-type": "application/json"}, content=content, request=request) + + +def _async_handler( + routes: dict[str, dict[str, Any]], +) -> tuple[Callable[[httpx.Request], httpx.Response], list[httpx.Request]]: + recorded: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + recorded.append(req) + return _resp(200, routes.get(req.url.path, {})) + + return handler, recorded + + +async def test_async_keys_provision_invokes_endpoint(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory() + routes = { + "/platform/provision-key": { + "api_key": "sk-real-secret-key-1234567890", + "key_name": "key-1", + "user_id": "user-1", + "expires": "2026-08-01T00:00:00Z", + } + } + handler, recorded = _async_handler(routes) + client = AsyncAskii( + token="jwt-1", + config=cfg, + http_client=httpx.AsyncClient(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) + resp = await client.keys.provision( + key_alias="alias-1", duration_days=30, memory_enabled=True, memory_mode=MemoryMode.PKG + ) + assert resp.api_key.get_secret_value() == "sk-real-secret-key-1234567890" + sent = json.loads(recorded[0].content) + assert sent["mpass_token"] == "jwt-1" + assert sent["duration_days"] == 30 + assert sent["memory_mode"] == "pkg" + await client.aclose() + + +async def test_async_keys_list_caches_when_ttl_given(config_factory: Callable[..., AskiiConfig]) -> None: + cache = InMemoryCache() + cfg = config_factory(cache=cache) + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(200, {"user_id": "u", "keys": []}) + + client = AsyncAskii( + token="jwt-1", + config=cfg, + http_client=httpx.AsyncClient(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) + await client.keys.list(cache_ttl=60) + await client.keys.list(cache_ttl=60) + assert seen["n"] == 1 + await client.aclose() + + +async def test_async_keys_provision_invalidates_list_cache(config_factory: Callable[..., AskiiConfig]) -> None: + cache = InMemoryCache() + cfg = config_factory(cache=cache) + seen = {"n": 0} + routes = { + "/platform/list-keys": {"user_id": "u", "keys": []}, + "/platform/provision-key": { + "api_key": "sk-abcdefgh12345678", + "key_name": "k", + "user_id": "u", + }, + } + + def handler(req: httpx.Request) -> httpx.Response: + if req.url.path == "/platform/list-keys": + seen["n"] += 1 + return _resp(200, routes[req.url.path]) + + client = AsyncAskii( + token="jwt-1", + config=cfg, + http_client=httpx.AsyncClient(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) + await client.keys.list(cache_ttl=60) + await client.keys.provision() # invalidates list cache + await client.keys.list(cache_ttl=60) + assert seen["n"] == 2 # cache was invalidated, second list re-fetches + await client.aclose() + + +async def test_async_request_escape_hatch_injects_token(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory() + recorded: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + recorded.append(req) + return _resp(200, {"raw": True}) + + client = AsyncAskii( + token="jwt-9", + config=cfg, + http_client=httpx.AsyncClient(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) + result = await client.request("POST", "/platform/future-endpoint", body={"a": 1}) + assert result == {"raw": True} + sent = json.loads(recorded[0].content) + assert sent == {"mpass_token": "jwt-9", "a": 1} + await client.aclose() + + +async def test_async_models_list(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory() + + def handler(req: httpx.Request) -> httpx.Response: + return _resp(200, {"models": [{"model_name": "gpt-4o"}]}) + + client = AsyncAskii( + token="jwt-1", + config=cfg, + http_client=httpx.AsyncClient(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) + resp = await client.models.list() + assert [m.model_name for m in resp.models] == ["gpt-4o"] + await client.aclose() + + +async def test_async_client_context_manager() -> None: + cfg = AskiiConfig(token="jwt-1", base_url="https://api.askii.test", max_retries=1) + async with AsyncAskii( + config=cfg, + http_client=httpx.AsyncClient( + base_url=cfg.base_url, + transport=httpx.MockTransport(lambda r: _resp(200, {"models": []})), + ), + ) as client: + await client.models.list() + # client is closed here; no further calls + + +def test_sync_keys_provision(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory() + + def handler(req: httpx.Request) -> httpx.Response: + return _resp( + 200, + { + "api_key": "sk-syncsyncsyncsyncsync", + "key_name": "k", + "user_id": "u", + }, + ) + + client = Askii( + token="jwt-1", + config=cfg, + http_client=httpx.Client(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) + resp = client.keys.provision() + assert resp.api_key.get_secret_value() == "sk-syncsyncsyncsyncsync" + client.close() + + +def test_sync_request_escape_hatch_injects_token(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory() + recorded: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + recorded.append(req) + return _resp(200, {"raw": True}) + + with Askii( + token="jwt-2", + config=cfg, + http_client=httpx.Client(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) as client: + result = client.request("POST", "/platform/anything", body={"b": 2}) + sent = json.loads(recorded[0].content) + assert sent == {"mpass_token": "jwt-2", "b": 2} + assert result == {"raw": True} + + +def test_sync_keys_list_with_cache(config_factory: Callable[..., AskiiConfig]) -> None: + cache = InMemoryCache() + cfg = config_factory(cache=cache) + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(200, {"user_id": "u", "keys": []}) + + with Askii( + token="jwt-1", + config=cfg, + http_client=httpx.Client(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) as client: + client.keys.list(cache_ttl=60) + client.keys.list(cache_ttl=60) + assert seen["n"] == 1 + + +def test_sync_keys_get_config_then_invalidate(config_factory: Callable[..., AskiiConfig]) -> None: + cache = InMemoryCache() + cfg = config_factory(cache=cache) + seen = {"get_config": 0} + + def handler(req: httpx.Request) -> httpx.Response: + if req.url.path == "/platform/get-key-config": + seen["get_config"] += 1 + return _resp(200, {"key_name": "k", "models": [], "memory_enabled": False}) + if req.url.path == "/platform/update-key-model": + return _resp(200, {"updated": True, "key_name": "k", "models": ["gpt-4o"]}) + return _resp(404, {"detail": "?"}) + + with Askii( + token="jwt-1", + config=cfg, + http_client=httpx.Client(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) as client: + client.keys.get_config(key="alias", cache_ttl=60) + client.keys.get_config(key="alias", cache_ttl=60) + assert seen["get_config"] == 1 + client.keys.update_model(key="alias", models=["gpt-4o"]) # invalidates + client.keys.get_config(key="alias", cache_ttl=60) + assert seen["get_config"] == 2 + + +def test_sync_async_callable_token_rejected() -> None: + async def provider() -> str: + return "x" + + cfg = AskiiConfig( + base_url="https://api.askii.test", + max_retries=1, + ) + from askii import AskiiAuthError + + with pytest.raises(AskiiAuthError): + Askii(token=provider, config=cfg) + + +def test_sync_revoke(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory() + + def handler(req: httpx.Request) -> httpx.Response: + return _resp(200, {"revoked": True, "detail": "ok"}) + + with Askii( + token="jwt-1", + config=cfg, + http_client=httpx.Client(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) as client: + resp = client.keys.revoke(key="alias") + assert resp.revoked is True diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..c43aac5 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,58 @@ +"""Tests for AskiiConfig and env handling.""" + +from __future__ import annotations + +import pytest + +from askii import AskiiConfig, Hooks, InMemoryCache +from askii._config import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_SECONDS + + +def test_defaults_are_sane() -> None: + cfg = AskiiConfig() + assert cfg.base_url == DEFAULT_BASE_URL + assert cfg.timeout == DEFAULT_TIMEOUT_SECONDS + assert cfg.max_retries == DEFAULT_MAX_RETRIES + assert cfg.http2 is True + assert cfg.default_cache_ttl == 0.0 + assert cfg.token is None + assert isinstance(cfg.cache, InMemoryCache) + assert isinstance(cfg.hooks, Hooks) + assert "askii-python" in cfg.user_agent + + +def test_config_is_frozen() -> None: + cfg = AskiiConfig() + with pytest.raises(Exception): # noqa: B017 — dataclasses.FrozenInstanceError + cfg.base_url = "x" # type: ignore[misc] + + +def test_from_env_reads_known_vars(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ASKII_BASE_URL", "https://override.test") + monkeypatch.setenv("ASKII_TOKEN", "tok-1") + monkeypatch.setenv("ASKII_TIMEOUT_SECONDS", "12.5") + monkeypatch.setenv("ASKII_MAX_RETRIES", "5") + cfg = AskiiConfig.from_env() + assert cfg.base_url == "https://override.test" + assert cfg.token == "tok-1" + assert cfg.timeout == 12.5 + assert cfg.max_retries == 5 + + +def test_from_env_kwargs_override_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ASKII_BASE_URL", "https://env.test") + cfg = AskiiConfig.from_env(base_url="https://kw.test") + assert cfg.base_url == "https://kw.test" + + +def test_from_env_without_vars_uses_defaults() -> None: + cfg = AskiiConfig.from_env() + assert cfg.base_url == DEFAULT_BASE_URL + assert cfg.token is None + + +def test_user_agent_includes_python_version() -> None: + import platform + + cfg = AskiiConfig() + assert platform.python_version() in cfg.user_agent diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py new file mode 100644 index 0000000..2f5d084 --- /dev/null +++ b/tests/unit/test_errors.py @@ -0,0 +1,156 @@ +"""Tests for the exception hierarchy and response → exception mapping.""" + +from __future__ import annotations + +import httpx +import pytest + +from askii import ( + AskiiAPIError, + AskiiAuthError, + AskiiNotFoundError, + AskiiRateLimitError, + AskiiServerError, + AskiiValidationError, + FieldError, +) +from askii._errors import map_response_to_error + + +def _make_response(status: int, body: object | None = None, headers: dict[str, str] | None = None) -> httpx.Response: + import json + + request = httpx.Request("POST", "https://api.askii.test/x") + content = b"" + final_headers = {"content-type": "application/json"} + if headers: + final_headers.update(headers) + if body is not None: + content = json.dumps(body).encode() + return httpx.Response(status, headers=final_headers, content=content, request=request) + + +def test_field_error_path_dots_loc() -> None: + fe = FieldError(loc=("body", "duration_days"), msg="too big", type="value_error") + assert fe.path == "body.duration_days" + + +def test_field_error_path_handles_ints() -> None: + fe = FieldError(loc=("body", 0, "key"), msg="x", type="t") + assert fe.path == "body.0.key" + + +def test_maps_401_to_auth_error() -> None: + err = map_response_to_error(_make_response(401, {"detail": "expired"})) + assert isinstance(err, AskiiAuthError) + assert err.status == 401 + + +def test_maps_403_to_auth_error() -> None: + err = map_response_to_error(_make_response(403, {"detail": "forbidden"})) + assert isinstance(err, AskiiAuthError) + + +def test_maps_404_to_not_found() -> None: + err = map_response_to_error(_make_response(404, {"detail": "missing"})) + assert isinstance(err, AskiiNotFoundError) + + +def test_maps_422_to_validation_error_with_field_errors() -> None: + body = { + "detail": [ + {"loc": ["body", "duration_days"], "msg": "out of range", "type": "value_error"}, + {"loc": ["body", "key_alias"], "msg": "too short", "type": "string_too_short"}, + ] + } + err = map_response_to_error(_make_response(422, body)) + assert isinstance(err, AskiiValidationError) + assert len(err.field_errors) == 2 + assert err.field_errors[0].path == "body.duration_days" + assert err.field_errors[1].type == "string_too_short" + + +def test_maps_422_with_non_list_detail_yields_empty_field_errors() -> None: + err = map_response_to_error(_make_response(422, {"detail": "boom"})) + assert isinstance(err, AskiiValidationError) + assert err.field_errors == [] + + +def test_maps_429_to_rate_limit_with_retry_after() -> None: + err = map_response_to_error(_make_response(429, {"detail": "slow down"}, {"retry-after": "5"})) + assert isinstance(err, AskiiRateLimitError) + assert err.retry_after == 5.0 + + +def test_429_with_invalid_retry_after_is_none() -> None: + err = map_response_to_error(_make_response(429, {"detail": "x"}, {"retry-after": "soon"})) + assert isinstance(err, AskiiRateLimitError) + assert err.retry_after is None + + +def test_maps_500_to_server_error() -> None: + err = map_response_to_error(_make_response(500, {"detail": "boom"})) + assert isinstance(err, AskiiServerError) + + +def test_maps_503_to_server_error() -> None: + assert isinstance(map_response_to_error(_make_response(503)), AskiiServerError) + + +def test_maps_418_to_generic_api_error() -> None: + err = map_response_to_error(_make_response(418, {"detail": "teapot"})) + assert isinstance(err, AskiiAPIError) + assert not isinstance(err, (AskiiAuthError, AskiiNotFoundError, AskiiValidationError)) + + +def test_request_id_pulled_from_header() -> None: + err = map_response_to_error(_make_response(500, {"detail": "x"}, {"x-request-id": "abc-123"})) + assert err.request_id == "abc-123" + + +def test_request_id_falls_back_to_correlation_id_header() -> None: + err = map_response_to_error(_make_response(500, {"detail": "x"}, {"x-correlation-id": "corr-42"})) + assert err.request_id == "corr-42" + + +def test_non_json_body_is_preserved_as_text() -> None: + request = httpx.Request("POST", "https://api.askii.test/x") + response = httpx.Response(500, content=b"plain text boom", request=request) + err = map_response_to_error(response) + assert isinstance(err, AskiiServerError) + assert "plain text boom" in str(err.detail) + + +def test_format_includes_request_id_when_present() -> None: + err = map_response_to_error(_make_response(401, {"detail": "x"}, {"x-request-id": "rid"})) + assert "rid" in str(err) + + +def test_validation_error_can_round_trip_field_errors() -> None: + fe = FieldError(loc=("a",), msg="m", type="t") + err = AskiiValidationError(422, "detail", field_errors=[fe]) + assert err.field_errors[0] is fe + + +def test_rate_limit_error_without_retry_after_is_none() -> None: + err = AskiiRateLimitError(429, "rl") + assert err.retry_after is None + + +def test_api_error_repr_includes_status() -> None: + err = AskiiAPIError(418, {"detail": "teapot"}) + assert "418" in str(err) + + +def test_validation_error_with_dict_loc_handles_tuple_input() -> None: + err = map_response_to_error(_make_response(422, {"detail": [{"loc": ("body", 1), "msg": "x", "type": "y"}]})) + assert isinstance(err, AskiiValidationError) + assert err.field_errors[0].loc == ("body", 1) + + +@pytest.mark.parametrize("status", [200, 201, 204, 299]) +def test_2xx_doesnt_map_to_error(status: int) -> None: + # map_response_to_error is only called for non-2xx; sanity check it still + # produces *something* and doesn't blow up. + err = map_response_to_error(_make_response(status, {"ok": True})) + assert isinstance(err, AskiiAPIError) diff --git a/tests/unit/test_hooks.py b/tests/unit/test_hooks.py new file mode 100644 index 0000000..8b807e9 --- /dev/null +++ b/tests/unit/test_hooks.py @@ -0,0 +1,52 @@ +"""Tests for the lifecycle hook dispatcher.""" + +from __future__ import annotations + +from askii import Hooks, RequestEvent, ResponseEvent, RetryEvent +from askii._hooks import _HooksProxy, empty_headers + + +def test_dispatch_invokes_callback() -> None: + seen: list[str] = [] + hooks = Hooks(on_cache_hit=seen.append) + proxy = _HooksProxy(hooks) + proxy.cache_hit("k1") + proxy.cache_hit("k2") + assert seen == ["k1", "k2"] + + +def test_dispatch_swallows_exceptions(caplog: object) -> None: + def kaboom(_: object) -> None: + raise RuntimeError("nope") + + hooks = Hooks(on_cache_miss=kaboom) + proxy = _HooksProxy(hooks) + # Should not propagate + proxy.cache_miss("k") + + +def test_dispatch_no_callback_is_noop() -> None: + proxy = _HooksProxy(Hooks()) + # All call sites should be safe with no callbacks set. + proxy.cache_hit("k") + proxy.cache_miss("k") + proxy.request(RequestEvent("GET", "/", "cid", 1, empty_headers())) + proxy.response(ResponseEvent("GET", "/", "cid", 1, 200, 1.0, None)) + proxy.error(RuntimeError()) + + +def test_request_event_is_frozen() -> None: + event = RequestEvent("GET", "/x", "cid", 1, empty_headers()) + import dataclasses + + assert dataclasses.is_dataclass(event) + import pytest + + with pytest.raises(dataclasses.FrozenInstanceError): + event.attempt = 2 # type: ignore[misc] + + +def test_retry_event_carries_exception() -> None: + exc = ValueError("boom") + event = RetryEvent("GET", "/x", "cid", 2, 0.5, exc) + assert event.exception is exc diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py new file mode 100644 index 0000000..e4f2ea0 --- /dev/null +++ b/tests/unit/test_logging.py @@ -0,0 +1,134 @@ +"""Tests for the logging helpers — redaction, correlation IDs, formatter.""" + +from __future__ import annotations + +import io +import json +import logging +from collections.abc import Iterator + +import pytest + +from askii import bind_correlation_id, configure_logging, get_correlation_id, reset_correlation_id +from askii._logging import ( + LOGGER_NAME, + CorrelationIdFilter, + JsonFormatter, + RedactionFilter, +) + + +@pytest.fixture +def captured_logger() -> Iterator[tuple[logging.Logger, io.StringIO]]: + stream = io.StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(JsonFormatter()) + handler.addFilter(CorrelationIdFilter()) + handler.addFilter(RedactionFilter()) + logger = logging.getLogger("askii.tests.cap") + logger.handlers = [handler] + logger.setLevel(logging.DEBUG) + logger.propagate = False + yield logger, stream + logger.handlers = [] + + +def _records(stream: io.StringIO) -> list[dict[str, object]]: + return [json.loads(line) for line in stream.getvalue().splitlines() if line.strip()] + + +def test_redaction_masks_known_keys(captured_logger: tuple[logging.Logger, io.StringIO]) -> None: + logger, stream = captured_logger + logger.info("auth", extra={"mpass_token": "ey.jwt.token", "api_key": "sk-real-secret-123abc"}) + record = _records(stream)[0] + assert record["mpass_token"] == "***redacted***" + assert record["api_key"] == "***redacted***" + + +def test_redaction_masks_inline_sk_in_message(captured_logger: tuple[logging.Logger, io.StringIO]) -> None: + logger, stream = captured_logger + logger.info("provisioned key sk-abcdef0123456789abc for user") + record = _records(stream)[0] + assert "sk-abc" not in record["message"] + assert "***redacted***" in record["message"] + + +def test_redaction_masks_jwt_pattern(captured_logger: tuple[logging.Logger, io.StringIO]) -> None: + logger, stream = captured_logger + jwt = "eyJhbGciOiJIUzI1NiJ9.eyJ4eHh4IjoieXl5eXkifQ.signature" + logger.info(f"got token {jwt}") + record = _records(stream)[0] + assert "eyJ" not in record["message"] + + +def test_redaction_handles_nested_dicts(captured_logger: tuple[logging.Logger, io.StringIO]) -> None: + logger, stream = captured_logger + logger.info("nested", extra={"body": {"authorization": "Bearer real", "ok": "yes"}}) + record = _records(stream)[0] + body = record["body"] + assert isinstance(body, dict) + assert body["authorization"] == "***redacted***" + assert body["ok"] == "yes" + + +def test_correlation_id_is_attached_to_record(captured_logger: tuple[logging.Logger, io.StringIO]) -> None: + logger, stream = captured_logger + token = bind_correlation_id("corr-42") + try: + logger.info("hello") + finally: + reset_correlation_id(token) + record = _records(stream)[0] + assert record["correlation_id"] == "corr-42" + + +def test_correlation_id_is_missing_when_unbound(captured_logger: tuple[logging.Logger, io.StringIO]) -> None: + logger, stream = captured_logger + logger.info("hello") + record = _records(stream)[0] + assert "correlation_id" not in record + + +def test_bind_correlation_id_generates_uuid_when_omitted() -> None: + token = bind_correlation_id() + try: + cid = get_correlation_id() + assert cid is not None + assert len(cid) >= 16 # uuid4 hex is 32 chars + finally: + reset_correlation_id(token) + + +def test_configure_logging_installs_json_handler() -> None: + logger = configure_logging(level="DEBUG", json=True, propagate=False) + assert logger.name == LOGGER_NAME + assert any(isinstance(h.formatter, JsonFormatter) for h in logger.handlers) + assert logger.level == logging.DEBUG + # Idempotent — call again and the handler list stays at 1. + configure_logging(level="DEBUG", json=True, propagate=False) + assert len(logger.handlers) == 1 + + +def test_configure_logging_can_use_plain_format() -> None: + logger = configure_logging(level="INFO", json=False, propagate=False) + assert logger.handlers + handler = logger.handlers[0] + assert handler.formatter is not None + assert not isinstance(handler.formatter, JsonFormatter) + + +def test_exception_info_appears_in_json() -> None: + stream = io.StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(JsonFormatter()) + handler.addFilter(CorrelationIdFilter()) + logger = logging.getLogger("askii.tests.exc") + logger.handlers = [handler] + logger.setLevel(logging.DEBUG) + try: + raise RuntimeError("boom") + except RuntimeError: + logger.exception("caught") + payload = json.loads(stream.getvalue().splitlines()[0]) + assert "boom" in payload["exception"] + logger.handlers = [] diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..9b6d096 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,128 @@ +"""Tests for the Pydantic request/response models.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from askii import ( + AvailableModelsResponse, + KeyConfig, + KeyInfo, + ListKeysResponse, + MemoryMode, + ProvisionKeyRequest, + ProvisionKeyResponse, + RevokeKeyResponse, + UpdateKeyModelResponse, +) + + +def test_memory_mode_enum_values() -> None: + assert MemoryMode.PKG.value == "pkg" + assert MemoryMode.CKG.value == "ckg" + assert MemoryMode.ALL.value == "all" + + +def test_provision_key_request_defaults() -> None: + req = ProvisionKeyRequest() + assert req.duration_days == 90 + assert req.memory_enabled is False + assert req.memory_mode is None + assert req.models is None + assert req.default_model is None + assert req.key_alias is None + + +def test_provision_key_request_duration_lower_bound() -> None: + with pytest.raises(ValidationError): + ProvisionKeyRequest(duration_days=0) + + +def test_provision_key_request_duration_upper_bound() -> None: + with pytest.raises(ValidationError): + ProvisionKeyRequest(duration_days=366) + + +def test_provision_key_request_dump_excludes_none() -> None: + req = ProvisionKeyRequest(key_alias="x", duration_days=10) + dumped = req.model_dump(exclude_none=True, mode="json") + assert "memory_mode" not in dumped + assert "models" not in dumped + assert "default_model" not in dumped + assert dumped["key_alias"] == "x" + assert dumped["duration_days"] == 10 + + +def test_provision_key_response_secret_str_is_obscured_in_repr() -> None: + resp = ProvisionKeyResponse( + api_key="sk-abc123abc123abc123", + key_name="key-1", + user_id="user-1", + ) + assert "sk-abc" not in repr(resp) + assert resp.api_key.get_secret_value() == "sk-abc123abc123abc123" + + +def test_extra_fields_are_rejected() -> None: + with pytest.raises(ValidationError): + ListKeysResponse(user_id="u", keys=[], surprise="bonus") # type: ignore[call-arg] + + +def test_key_info_round_trip() -> None: + payload = { + "key_name": "n", + "key_alias": "a", + "spend": 1.5, + "models": ["gpt-4o"], + "memory_enabled": True, + "memory_mode": "ckg", + } + info = KeyInfo.model_validate(payload) + assert info.spend == 1.5 + assert info.memory_mode is MemoryMode.CKG + + +def test_list_keys_response_parses_dates() -> None: + payload = { + "user_id": "u-1", + "keys": [ + { + "key_name": "n", + "spend": 0.0, + "models": [], + "memory_enabled": False, + "created_at": "2026-05-01T00:00:00Z", + "expires": "2026-08-01T00:00:00Z", + } + ], + } + parsed = ListKeysResponse.model_validate(payload) + assert parsed.keys[0].created_at is not None + assert parsed.keys[0].expires is not None + + +def test_revoke_key_response_parses() -> None: + resp = RevokeKeyResponse.model_validate({"revoked": True, "detail": "ok"}) + assert resp.revoked is True + + +def test_key_config_parses() -> None: + cfg = KeyConfig.model_validate({"key_name": "n", "models": [], "memory_enabled": False}) + assert cfg.memory_enabled is False + assert cfg.models == [] + + +def test_update_key_model_response_parses() -> None: + resp = UpdateKeyModelResponse.model_validate( + {"updated": True, "key_name": "n", "models": ["gpt-4o"], "default_model": "gpt-4o"} + ) + assert resp.updated is True + assert resp.default_model == "gpt-4o" + + +def test_available_models_response_parses_model_name() -> None: + resp = AvailableModelsResponse.model_validate( + {"models": [{"model_name": "gpt-4o"}, {"model_name": "claude-sonnet-4"}]} + ) + assert [m.model_name for m in resp.models] == ["gpt-4o", "claude-sonnet-4"] diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py new file mode 100644 index 0000000..a89a3a6 --- /dev/null +++ b/tests/unit/test_retry.py @@ -0,0 +1,132 @@ +"""Tests for the tenacity retry policy factory.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from askii import AskiiConfig, AskiiRateLimitError, AskiiServerError, AskiiTimeoutError +from askii._retry import _AskiiWait, build_async_retry, build_sync_retry + + +@pytest.fixture +def cfg(config_factory: Callable[..., AskiiConfig]) -> AskiiConfig: + return config_factory(max_retries=4) + + +def test_sync_retry_runs_until_success(cfg: AskiiConfig) -> None: + runner = build_sync_retry(cfg) + attempts = {"n": 0} + + def call() -> int: + attempts["n"] += 1 + if attempts["n"] < 3: + raise AskiiServerError(500, "boom") + return 42 + + result: int | None = None + for attempt in runner: + with attempt: + result = call() + assert result == 42 + assert attempts["n"] == 3 + + +def test_sync_retry_gives_up_after_max_attempts(cfg: AskiiConfig) -> None: + runner = build_sync_retry(cfg) + attempts = {"n": 0} + + def call() -> None: + attempts["n"] += 1 + raise AskiiServerError(500, "boom") + + with pytest.raises(AskiiServerError): + for attempt in runner: + with attempt: + call() + assert attempts["n"] == 4 + + +def test_sync_retry_doesnt_retry_unknown_errors(cfg: AskiiConfig) -> None: + runner = build_sync_retry(cfg) + attempts = {"n": 0} + + def call() -> None: + attempts["n"] += 1 + raise ValueError("unrelated") + + with pytest.raises(ValueError): + for attempt in runner: + with attempt: + call() + assert attempts["n"] == 1 + + +async def test_async_retry_runs_until_success(cfg: AskiiConfig) -> None: + runner = build_async_retry(cfg) + attempts = {"n": 0} + + async def call() -> int: + attempts["n"] += 1 + if attempts["n"] < 2: + raise AskiiTimeoutError("slow") + return 7 + + result: int | None = None + async for attempt in runner: + with attempt: + result = await call() + assert result == 7 + assert attempts["n"] == 2 + + +def test_wait_strategy_honors_retry_after() -> None: + wait = _AskiiWait(initial=0.1, max_wait=2.0) + + class _State: + attempt_number = 2 + outcome: Any + + state = _State() + + class _Outcome: + failed = True + + def exception(self) -> BaseException: + return AskiiRateLimitError(429, "rl", retry_after=3.5) + + state.outcome = _Outcome() + assert wait(state) == 3.5 # type: ignore[arg-type] + + +def test_wait_strategy_falls_back_to_exponential() -> None: + wait = _AskiiWait(initial=0.1, max_wait=2.0) + + class _State: + attempt_number = 3 + outcome: Any = None + + sleep = wait(_State()) # type: ignore[arg-type] + # On attempt 3, base = 0.1 * 2^2 = 0.4, clamped to 2.0; jitter halves to [0.2, 0.4). + assert 0.0 < sleep <= 2.0 + + +def test_wait_caps_retry_after_at_60s() -> None: + wait = _AskiiWait(initial=0.1, max_wait=2.0) + + class _State: + attempt_number = 1 + outcome: Any + + state = _State() + + class _Outcome: + failed = True + + def exception(self) -> BaseException: + return AskiiRateLimitError(429, "rl", retry_after=99999) + + state.outcome = _Outcome() + assert wait(state) == 60.0 # type: ignore[arg-type] diff --git a/tests/unit/test_token_resolver.py b/tests/unit/test_token_resolver.py new file mode 100644 index 0000000..3dc2e7d --- /dev/null +++ b/tests/unit/test_token_resolver.py @@ -0,0 +1,101 @@ +"""Tests for the token resolver factories.""" + +from __future__ import annotations + +import pytest + +from askii import AskiiAuthError +from askii._token import make_async_resolver, make_sync_resolver + +# --- async resolver -------------------------------------------------- + + +async def test_async_resolver_from_static_string() -> None: + resolver = make_async_resolver("jwt-abc") + assert await resolver() == "jwt-abc" + + +async def test_async_resolver_from_sync_callable() -> None: + counter = {"n": 0} + + def provider() -> str: + counter["n"] += 1 + return f"jwt-{counter['n']}" + + resolver = make_async_resolver(provider) + assert await resolver() == "jwt-1" + assert await resolver() == "jwt-2" + + +async def test_async_resolver_from_async_callable() -> None: + async def provider() -> str: + return "jwt-async" + + resolver = make_async_resolver(provider) + assert await resolver() == "jwt-async" + + +async def test_async_resolver_rejects_empty_string() -> None: + with pytest.raises(AskiiAuthError): + make_async_resolver("") + + +async def test_async_resolver_rejects_none() -> None: + with pytest.raises(AskiiAuthError): + make_async_resolver(None) + + +async def test_async_resolver_rejects_unsupported_type() -> None: + with pytest.raises(AskiiAuthError): + make_async_resolver(42) # type: ignore[arg-type] + + +async def test_async_resolver_rejects_callable_returning_empty() -> None: + resolver = make_async_resolver(lambda: "") + with pytest.raises(AskiiAuthError): + await resolver() + + +# --- sync resolver --------------------------------------------------- + + +def test_sync_resolver_from_static_string() -> None: + resolver = make_sync_resolver("jwt-static") + assert resolver() == "jwt-static" + + +def test_sync_resolver_from_callable() -> None: + resolver = make_sync_resolver(lambda: "jwt-call") + assert resolver() == "jwt-call" + + +def test_sync_resolver_rejects_none() -> None: + with pytest.raises(AskiiAuthError): + make_sync_resolver(None) + + +def test_sync_resolver_rejects_unsupported() -> None: + with pytest.raises(AskiiAuthError): + make_sync_resolver(42) # type: ignore[arg-type] + + +def test_sync_resolver_rejects_async_def_at_construction() -> None: + async def provider() -> str: + return "x" + + with pytest.raises(AskiiAuthError): + make_sync_resolver(provider) + + +def test_sync_resolver_rejects_sync_callable_returning_awaitable() -> None: + """A plain sync function that returns a coroutine (unusual but possible) is rejected at call time.""" + + async def _coro() -> str: + return "x" + + def trampoline() -> object: + return _coro() + + resolver = make_sync_resolver(trampoline) + with pytest.raises(AskiiAuthError): + resolver() diff --git a/tests/unit/test_transport_async.py b/tests/unit/test_transport_async.py new file mode 100644 index 0000000..e0ba0d9 --- /dev/null +++ b/tests/unit/test_transport_async.py @@ -0,0 +1,221 @@ +"""End-to-end tests for the async transport using ``httpx.MockTransport``.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +import httpx +import pytest + +from askii import ( + AskiiAuthError, + AskiiConfig, + AskiiServerError, + AskiiValidationError, + Hooks, + InMemoryCache, +) +from askii._transport import HTTPTransport + + +def _resp( + status: int = 200, + body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> httpx.Response: + content = json.dumps(body or {}).encode() + request = httpx.Request("POST", "https://api.askii.test/x") + return httpx.Response( + status, + headers={"content-type": "application/json", **(headers or {})}, + content=content, + request=request, + ) + + +def _transport( + cfg: AskiiConfig, + handler: Callable[[httpx.Request], httpx.Response], +) -> HTTPTransport: + mock = httpx.MockTransport(handler) + client = httpx.AsyncClient(base_url=cfg.base_url, transport=mock, timeout=cfg.timeout) + return HTTPTransport(cfg, sync=False, http_client=client) + + +async def test_happy_path_returns_parsed_dict(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory() + + def handler(req: httpx.Request) -> httpx.Response: + body = json.loads(req.content) + assert body == {"foo": "bar"} + return _resp(200, {"ok": True}) + + transport = _transport(cfg, handler) + result = await transport.arequest("POST", "/x", body={"foo": "bar"}) + assert result == {"ok": True} + await transport.aclose() + + +async def test_204_returns_empty_dict(config_factory: Callable[..., AskiiConfig]) -> None: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(204, request=req) + + transport = _transport(config_factory(), handler) + assert await transport.arequest("POST", "/x", body={}) == {} + await transport.aclose() + + +async def test_retries_on_5xx_until_success(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory(max_retries=3) + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + if seen["n"] < 3: + return _resp(500, {"detail": "boom"}) + return _resp(200, {"ok": True}) + + transport = _transport(cfg, handler) + result = await transport.arequest("POST", "/x", body={}) + assert result == {"ok": True} + assert seen["n"] == 3 + await transport.aclose() + + +async def test_gives_up_after_max_attempts(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory(max_retries=2) + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(500, {"detail": "boom"}) + + transport = _transport(cfg, handler) + with pytest.raises(AskiiServerError): + await transport.arequest("POST", "/x", body={}) + assert seen["n"] == 2 + await transport.aclose() + + +async def test_does_not_retry_on_401(config_factory: Callable[..., AskiiConfig]) -> None: + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(401, {"detail": "expired"}) + + transport = _transport(config_factory(max_retries=3), handler) + with pytest.raises(AskiiAuthError): + await transport.arequest("POST", "/x", body={}) + assert seen["n"] == 1 + await transport.aclose() + + +async def test_422_raises_validation_error(config_factory: Callable[..., AskiiConfig]) -> None: + body = { + "detail": [ + {"loc": ["body", "duration_days"], "msg": "bad", "type": "value_error"}, + ] + } + + def handler(req: httpx.Request) -> httpx.Response: + return _resp(422, body) + + transport = _transport(config_factory(max_retries=1), handler) + with pytest.raises(AskiiValidationError) as excinfo: + await transport.arequest("POST", "/x", body={}) + assert excinfo.value.field_errors[0].path == "body.duration_days" + await transport.aclose() + + +async def test_correlation_id_header_is_sent(config_factory: Callable[..., AskiiConfig]) -> None: + seen_headers: list[dict[str, str]] = [] + + def handler(req: httpx.Request) -> httpx.Response: + seen_headers.append(dict(req.headers)) + return _resp(200, {"ok": True}) + + transport = _transport(config_factory(), handler) + await transport.arequest("POST", "/x", body={}) + assert "x-correlation-id" in seen_headers[0] + await transport.aclose() + + +async def test_cache_hit_skips_network(config_factory: Callable[..., AskiiConfig]) -> None: + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(200, {"v": seen["n"]}) + + cache = InMemoryCache() + cfg = config_factory(cache=cache, default_cache_ttl=60) + transport = _transport(cfg, handler) + first = await transport.arequest("POST", "/x", body={}, cache_key="k", cache_ttl=60) + second = await transport.arequest("POST", "/x", body={}, cache_key="k", cache_ttl=60) + assert first == {"v": 1} + assert second == {"v": 1} # cache hit + assert seen["n"] == 1 + await transport.aclose() + + +async def test_hooks_receive_request_and_response_events(config_factory: Callable[..., AskiiConfig]) -> None: + events: list[tuple[str, Any]] = [] + + hooks = Hooks( + on_request=lambda e: events.append(("request", e)), + on_response=lambda e: events.append(("response", e)), + ) + + def handler(req: httpx.Request) -> httpx.Response: + return _resp(200, {"ok": True}) + + transport = _transport(config_factory(hooks=hooks), handler) + await transport.arequest("POST", "/x", body={}) + kinds = [k for k, _ in events] + assert kinds == ["request", "response"] + await transport.aclose() + + +async def test_hook_callback_exception_is_swallowed(config_factory: Callable[..., AskiiConfig]) -> None: + def boom(_: Any) -> None: + raise RuntimeError("test") + + hooks = Hooks(on_request=boom) + + def handler(req: httpx.Request) -> httpx.Response: + return _resp(200, {"ok": True}) + + transport = _transport(config_factory(hooks=hooks), handler) + # No exception expected + assert await transport.arequest("POST", "/x", body={}) == {"ok": True} + await transport.aclose() + + +async def test_sync_method_raises_on_async_transport(config_factory: Callable[..., AskiiConfig]) -> None: + transport = _transport(config_factory(), lambda req: _resp(200)) + with pytest.raises(RuntimeError): + transport.request("POST", "/x", body={}) + await transport.aclose() + + +async def test_constructor_rejects_wrong_client_type(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory() + sync_client = httpx.Client(base_url=cfg.base_url, transport=httpx.MockTransport(lambda r: _resp(200))) + with pytest.raises(TypeError): + HTTPTransport(cfg, sync=False, http_client=sync_client) # type: ignore[arg-type] + sync_client.close() + + +async def test_network_error_maps_to_connection_error(config_factory: Callable[..., AskiiConfig]) -> None: + def handler(req: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("nope") + + transport = _transport(config_factory(max_retries=1), handler) + from askii import AskiiConnectionError + + with pytest.raises(AskiiConnectionError): + await transport.arequest("POST", "/x", body={}) + await transport.aclose() diff --git a/tests/unit/test_transport_sync.py b/tests/unit/test_transport_sync.py new file mode 100644 index 0000000..6619905 --- /dev/null +++ b/tests/unit/test_transport_sync.py @@ -0,0 +1,121 @@ +"""End-to-end tests for the sync transport using ``httpx.MockTransport``.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any + +import httpx +import pytest + +from askii import ( + AskiiAuthError, + AskiiConfig, + AskiiConnectionError, + AskiiServerError, + InMemoryCache, +) +from askii._transport import HTTPTransport + + +def _resp(status: int = 200, body: dict[str, Any] | None = None) -> httpx.Response: + content = json.dumps(body or {}).encode() + request = httpx.Request("POST", "https://api.askii.test/x") + return httpx.Response(status, headers={"content-type": "application/json"}, content=content, request=request) + + +def _transport( + cfg: AskiiConfig, + handler: Callable[[httpx.Request], httpx.Response], +) -> HTTPTransport: + mock = httpx.MockTransport(handler) + client = httpx.Client(base_url=cfg.base_url, transport=mock, timeout=cfg.timeout) + return HTTPTransport(cfg, sync=True, http_client=client) + + +def test_sync_happy_path(config_factory: Callable[..., AskiiConfig]) -> None: + transport = _transport(config_factory(), lambda req: _resp(200, {"v": 1})) + assert transport.request("POST", "/x", body={}) == {"v": 1} + transport.close() + + +def test_sync_5xx_retries(config_factory: Callable[..., AskiiConfig]) -> None: + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + if seen["n"] < 3: + return _resp(500, {"detail": "x"}) + return _resp(200, {"ok": True}) + + transport = _transport(config_factory(max_retries=3), handler) + assert transport.request("POST", "/x", body={}) == {"ok": True} + assert seen["n"] == 3 + transport.close() + + +def test_sync_401_no_retry(config_factory: Callable[..., AskiiConfig]) -> None: + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(401, {"detail": "x"}) + + transport = _transport(config_factory(max_retries=3), handler) + with pytest.raises(AskiiAuthError): + transport.request("POST", "/x", body={}) + assert seen["n"] == 1 + transport.close() + + +def test_sync_cache_hit(config_factory: Callable[..., AskiiConfig]) -> None: + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(200, {"v": seen["n"]}) + + cache = InMemoryCache() + cfg = config_factory(cache=cache) + transport = _transport(cfg, handler) + first = transport.request("POST", "/x", body={}, cache_key="k", cache_ttl=60) + second = transport.request("POST", "/x", body={}, cache_key="k", cache_ttl=60) + assert first == second + assert seen["n"] == 1 + transport.close() + + +def test_sync_constructor_rejects_async_client(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory() + aclient = httpx.AsyncClient(base_url=cfg.base_url, transport=httpx.MockTransport(lambda r: _resp(200))) + with pytest.raises(TypeError): + HTTPTransport(cfg, sync=True, http_client=aclient) # type: ignore[arg-type] + + +def test_sync_method_raises_on_async_transport_object(config_factory: Callable[..., AskiiConfig]) -> None: + cfg = config_factory() + aclient = httpx.AsyncClient(base_url=cfg.base_url, transport=httpx.MockTransport(lambda r: _resp(200))) + transport = HTTPTransport(cfg, sync=False, http_client=aclient) + with pytest.raises(RuntimeError): + transport.request("POST", "/x", body={}) + + +def test_sync_returns_500_after_retries(config_factory: Callable[..., AskiiConfig]) -> None: + def handler(req: httpx.Request) -> httpx.Response: + return _resp(500, {"detail": "boom"}) + + transport = _transport(config_factory(max_retries=2), handler) + with pytest.raises(AskiiServerError): + transport.request("POST", "/x", body={}) + transport.close() + + +def test_sync_connection_error_maps(config_factory: Callable[..., AskiiConfig]) -> None: + def handler(req: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("no route") + + transport = _transport(config_factory(max_retries=1), handler) + with pytest.raises(AskiiConnectionError): + transport.request("POST", "/x", body={}) + transport.close() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8ab167e --- /dev/null +++ b/uv.lock @@ -0,0 +1,1335 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "askii" +source = { editable = "." } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "rich" }, + { name = "tenacity" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "fakeredis" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-recording" }, + { name = "ruff" }, + { name = "types-redis" }, +] +redis = [ + { name = "redis" }, +] + +[package.metadata] +requires-dist = [ + { name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.21" }, + { name = "httpx", extras = ["http2"], specifier = ">=0.27" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "pydantic", specifier = ">=2.6" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" }, + { name = "pytest-recording", marker = "extra == 'dev'", specifier = ">=0.13" }, + { name = "redis", marker = "extra == 'redis'", specifier = ">=5.0" }, + { name = "rich", specifier = ">=13.7" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, + { name = "tenacity", specifier = ">=8.2" }, + { name = "typer", specifier = ">=0.12" }, + { name = "types-redis", marker = "extra == 'dev'" }, +] +provides-extras = ["dev", "redis"] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" }, + { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" }, + { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" }, + { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" }, + { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" }, + { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" }, + { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, + { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "48.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/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = "fakeredis" +version = "2.35.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/50/b748233c02fa77e5105238190cc9bb58b852eb1c8b1d0763230d3a5b745a/fakeredis-2.35.1.tar.gz", hash = "sha256:5bae5eba7b9d93cb968944ac40936373cf2397ff71667d4b595df65c3d2e413f", size = 189118, upload-time = "2026-04-12T17:05:58.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/27/b8b057a23f7777177e92d3a602fd866751b6b45014964548997e92e048fd/fakeredis-2.35.1-py3-none-any.whl", hash = "sha256:67d97e11f562b7870e11e5c30cf182270bfb2dd37f6707dba47cc6d91628d1b9", size = 129678, upload-time = "2026-04-12T17:05:56.86Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { 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 = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +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" } +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" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.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/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[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 = "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 = "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 = "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 = "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 = "pytest" +version = "9.0.3" +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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-recording" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "vcrpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/9c/f4027c5f1693847b06d11caf4b4f6bb09f22c1581ada4663877ec166b8c6/pytest_recording-0.13.4.tar.gz", hash = "sha256:568d64b2a85992eec4ae0a419c855d5fd96782c5fb016784d86f18053792768c", size = 26576, upload-time = "2025-05-08T10:41:11.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/c2/ce34735972cc42d912173e79f200fe66530225190c06655c5632a9d88f1e/pytest_recording-0.13.4-py3-none-any.whl", hash = "sha256:ad49a434b51b1c4f78e85b1e6b74fdcc2a0a581ca16e52c798c6ace971f7f439", size = 13723, upload-time = "2025-05-08T10:41:09.684Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[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 = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "types-cffi" +version = "2.0.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/0b/b352742758a6054d1053783887bf8cfb739deda1102fda8722294bdc01f7/types_cffi-2.0.0.20260518.tar.gz", hash = "sha256:f9707e66c13454789a58f8843d1ded4a66f1e9c8b10bd24d5eb5e0f25c0c5472", size = 17790, upload-time = "2026-05-18T06:06:50.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/44/d3b4aafa20a3f76384ba19a513d39272add13746dcfe0409d8d4974fd464/types_cffi-2.0.0.20260518-py3-none-any.whl", hash = "sha256:5b68a215a95d0eac4203b58e766ff7fe40c2e091b1fa1a9e54111f04cc560084", size = 20198, upload-time = "2026-05-18T06:06:49.83Z" }, +] + +[[package]] +name = "types-pyopenssl" +version = "24.1.0.20240722" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "types-cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/29/47a346550fd2020dac9a7a6d033ea03fccb92fa47c726056618cc889745e/types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39", size = 8458, upload-time = "2024-07-22T02:32:22.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/05/c868a850b6fbb79c26f5f299b768ee0adc1f9816d3461dcf4287916f655b/types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54", size = 7499, upload-time = "2024-07-22T02:32:21.232Z" }, +] + +[[package]] +name = "types-redis" +version = "4.6.0.20241004" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "types-pyopenssl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/95/c054d3ac940e8bac4ca216470c80c26688a0e79e09f520a942bb27da3386/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", size = 49679, upload-time = "2024-10-04T02:43:59.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/82/7d25dce10aad92d2226b269bce2f85cfd843b4477cd50245d7d40ecf8f89/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed", size = 58737, upload-time = "2024-10-04T02:43:57.968Z" }, +] + +[[package]] +name = "types-setuptools" +version = "82.0.0.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/bc/73c2c27e047e42f114ac50fb3bdef986c56cbdb68096f8690eeafb839a93/types_setuptools-82.0.0.20260518.tar.gz", hash = "sha256:3b743cfe63d0981ea4c15b90710fc1ed41e3464a537d51e705be514e891c1d07", size = 44999, upload-time = "2026-05-18T06:02:55.642Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/8f/d5e2d493f09a7a98c95619edda1cb37cee377626c0a869d53274c26f2858/types_setuptools-82.0.0.20260518-py3-none-any.whl", hash = "sha256:31c04a62b57a653a5021caf191be0f10f70df890f813b51f02bab3969d300f20", size = 68444, upload-time = "2026-05-18T06:02:54.582Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "vcrpy" +version = "8.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/07/bcfd5ebd7cb308026ab78a353e091bd699593358be49197d39d004e5ad83/vcrpy-8.1.1.tar.gz", hash = "sha256:58e3053e33b423f3594031cb758c3f4d1df931307f1e67928e30cf352df7709f", size = 85770, upload-time = "2026-01-04T19:22:03.886Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/d7/f79b05a5d728f8786876a7d75dfb0c5cae27e428081b2d60152fb52f155f/vcrpy-8.1.1-py3-none-any.whl", hash = "sha256:2d16f31ad56493efb6165182dd99767207031b0da3f68b18f975545ede8ac4b9", size = 42445, upload-time = "2026-01-04T19:22:02.532Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f0/5e969d268d59e6035f2f1960da9e82fe6db24a7b8abe8e36a78c27cb3e2b/wrapt-2.2.0.tar.gz", hash = "sha256:b70a0b75b0a5a58d04aad06b3f167d49e729381d3417413656220c0cd7617847", size = 125173, upload-time = "2026-05-21T04:51:39.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/c6/17263421accbbc27bc4c8535eb9215a18a914d15eab4829a59e93f5ad29d/wrapt-2.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b3946f0ff079623dc4f117363040433be390bfebce3719de50dfecbf31efdf0", size = 80088, upload-time = "2026-05-21T04:49:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/40/0d/81230469d6a7c6878e0763b7d84ebab6da3625ce62e8fd83086c982b8726/wrapt-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a50822bbbefb90b132a780c17356062a2452cd5525bfa4b5b596fd6474cceaa6", size = 81177, upload-time = "2026-05-21T04:49:12.589Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/a09c8346f270ab1328ba9e6594d73d86450de22bc4d29a23167ff82d7ec1/wrapt-2.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:29c0b2c075f8854b3345be584ab3d84f8968c45605d1914be1c94939cef5d702", size = 152069, upload-time = "2026-05-21T04:49:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/40/5e/79b6d6295733b9fa1bee096120a556366951e3c0140234310080ede40e42/wrapt-2.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f0d4a79d9af893d80caa5b709e024dd2d387f3f047008286036143f118d7010", size = 154319, upload-time = "2026-05-21T04:49:16.097Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4d/a72b95e9389a4f350150d9a3ce9b263bad16f476551004a12de167ae7d0b/wrapt-2.2.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10e8f78948d13369b770fc17bf72272aac98b4b92d49a38f479abf718f6b615b", size = 148874, upload-time = "2026-05-21T04:49:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/0a/56/ffec9a08beb6fcfc30b259c6b8b36741675c58de69f1c035746f06fa4a07/wrapt-2.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a4482d1d4108052827b354850bd6e3d1ed56262cbe4b0e8051876c298fb99280", size = 153250, upload-time = "2026-05-21T04:49:19.413Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c5/7ab2e23d594f28b2fc00bd19e82163bce2f77e2bc916e9dc247e0f886a41/wrapt-2.2.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:43c36019a690b2cb089665eab01a50c92d814553c6e57ff03d2c68e63ce8f00b", size = 147902, upload-time = "2026-05-21T04:49:20.749Z" }, + { url = "https://files.pythonhosted.org/packages/74/61/565965b9613dccf20286880e314cc41b20a85b2f4a7fe275786bb08b330e/wrapt-2.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cb9336f2dc99de00c9e58487cae5541ee4d79e859377b6312d98973d4661c584", size = 151334, upload-time = "2026-05-21T04:49:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/32/0e/1890765d97cc3016ba444f8158856a35f8944785660eb88ff73b2d1e2b9b/wrapt-2.2.0-cp310-cp310-win32.whl", hash = "sha256:63a09b40bba3b2482983e2aeba6e45e20e1f567821ac89c8922229ecc1de7f65", size = 77405, upload-time = "2026-05-21T04:49:24.43Z" }, + { url = "https://files.pythonhosted.org/packages/02/02/a943f4d0f9084a354a722468ff2899e9177449f03f4bff8ef234792f27ad/wrapt-2.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:2ff803b3607cd76cb9b853b03d15279c7ffc8ba69e69f76304cd23d2722f2b65", size = 80353, upload-time = "2026-05-21T04:49:25.87Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c2/2c7838cf368c04aebaef93f756f5b76e0eb12bb710c2926111dc96e5aaf9/wrapt-2.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:af17d3ce1e2cc5d22ae8fe8921d7801c980ea3f5d6da4ecbd0f85c4f9e030181", size = 79121, upload-time = "2026-05-21T04:49:27.778Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2e/a3eb4a1ef48fc743c4107e82d5b1144287ef8353b0f6844fee1add28d663/wrapt-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b93e1ccddbdf59cec4f7683dc84bc56eb61628eb01b22bdefc15f04cd09f8fae", size = 80324, upload-time = "2026-05-21T04:49:29.402Z" }, + { url = "https://files.pythonhosted.org/packages/0e/23/03248de44165f9c06dc23da981f3d58889ee2600004289c7afd12ef316b1/wrapt-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97fbe7a0df35afe37e7e2f053dee6300a3eed00055cfd907fa51161e22c40236", size = 81201, upload-time = "2026-05-21T04:49:30.691Z" }, + { url = "https://files.pythonhosted.org/packages/39/99/ed8c0f9f0d3c9631259bf5c5d776ec7a70d6d888ce060ad4758f00a29683/wrapt-2.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d8f6cf451ec4aab0cdbad128d9be1219e95ceaa9940566d71570b2d820ee50b3", size = 158770, upload-time = "2026-05-21T04:49:32.299Z" }, + { url = "https://files.pythonhosted.org/packages/25/fc/6eed4204b30562f113e40151b94ec1ee565c040d90623a4223742cf5aa68/wrapt-2.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f1dc1d1a2f0b081d8c1eef2203e61717b537a1bcb0d8e4d1405aeb15aa85c34", size = 160322, upload-time = "2026-05-21T04:49:33.959Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3d/cb9d33c140cce69e025d946deac44c636ce16a079cd4410722b552aecb5e/wrapt-2.2.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952ec99e71d584a0e451795dbd468909c8794727ecddd9ebb4fe9803e2803f1e", size = 153088, upload-time = "2026-05-21T04:49:35.715Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fd/e452de05a75c008acef9055dd9a58fc6a4d08a5e42747394a91030f83169/wrapt-2.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33ff34dc349320dc16ebe0cdf70dddf5ae9328f4a448823a00f37976d0cc2234", size = 159258, upload-time = "2026-05-21T04:49:38.249Z" }, + { url = "https://files.pythonhosted.org/packages/f1/03/c06ee1605a5b11da535b64e26c9f2330de7a8e3a2253afc533f37a5a682f/wrapt-2.2.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d23ea5a8e4ae99640d027d2fd05c9d03f8d24d561fc26c0462e96affa31bf408", size = 152155, upload-time = "2026-05-21T04:49:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/d7cb1d184afe5a1db15515f86758fd08fa795a650f2af18ff221758921d7/wrapt-2.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9c95f72d212e1f178f9619b77fd7ee3533e82ded6a5ad119dd88134e185ee3b0", size = 157920, upload-time = "2026-05-21T04:49:41.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/80/5bbdade010313edbb14afbdd916a054c74c99c2f04b0f8358086c728815a/wrapt-2.2.0-cp311-cp311-win32.whl", hash = "sha256:db93eebcf951f9ee41d75dc0423378fa918fc6706db59bc20c02f6563b6b210d", size = 77572, upload-time = "2026-05-21T04:49:42.913Z" }, + { url = "https://files.pythonhosted.org/packages/5f/32/9df5dd381c2d4d9f14d8d442de4efd8ef8fda3df8b25a384e7060a6d91a8/wrapt-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:22c7ee3a3737d9656ddf2c9cc1f1548ec963d966251e899561da142697d33a9d", size = 80624, upload-time = "2026-05-21T04:49:44.411Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5c/3c441a01c9e1f072f0a9c062a3aa709b3fe488af649ecb0b74206e5a9754/wrapt-2.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:7e291fa9129d9998ed5035390d4bb9cf429c489f40e5ddaa06a1e83ed52048a7", size = 79003, upload-time = "2026-05-21T04:49:45.687Z" }, + { url = "https://files.pythonhosted.org/packages/83/ac/0d40f7f625b78d698dd8fcaf2df31585d2185dd0c261b82f7cc334c53168/wrapt-2.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8a76b27fe0d600f8a34313e1a528309aa807a16aa3a72000619bc56339020125", size = 80992, upload-time = "2026-05-21T04:49:47.024Z" }, + { url = "https://files.pythonhosted.org/packages/a0/56/bec7ac3b1c40bee400aecf0db3abee9d3461fd8f02eb42fb02693092b3d9/wrapt-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:778aa2f59615973f2637d9025a708b69196c4814f38d905647fa1a56d7ff6b79", size = 81648, upload-time = "2026-05-21T04:49:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a9/6ecf97645bde3fc5faa980516f7007ece0b38d3219e5add54042d3ae8b4e/wrapt-2.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5b7f10aa09d1f5abfe3ccd022dec566a5010465b98b3755cc0705a762547101f", size = 168683, upload-time = "2026-05-21T04:49:49.703Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/de03c995ade9b215f2c019be6442fc206b05ddcbec9d2f81bf94157aef47/wrapt-2.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d98bf0078736df226e36875aa58a78f9d3b0888bcf585144fb30edbbf7145238", size = 170982, upload-time = "2026-05-21T04:49:51.167Z" }, + { url = "https://files.pythonhosted.org/packages/19/f8/6255eb9827dbd137569de68554b1e9535c3ac79cdbc377af3da415891807/wrapt-2.2.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b62f40eb24ccf05246d203461c8920889fd38dce76978df16fe28e6f0128447d", size = 160002, upload-time = "2026-05-21T04:49:53.598Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dd/962a9281d9c35e21c5a662c7d05c2af0108a3c833d2d6ab2eb546e520f7e/wrapt-2.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8ce59cad2ee5a4d58ee647c4ed4d9adc4282ffdc31e98cba7f831536776a0f9", size = 168827, upload-time = "2026-05-21T04:49:55.082Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ef/6a10e1200b2238be6da767d1814ab298f20e533a6c210f9ae6423ee3139c/wrapt-2.2.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:bb7c060c3faa78fe066b6b1c65de285d8d61fb6e01ee8195625b9636c3cd9775", size = 158164, upload-time = "2026-05-21T04:49:56.587Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b2/4f5f4c722aa730eb2c0723ee8f32d0d7315d07173cdac0d08b7b92bbab39/wrapt-2.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4297b7338cfa48b5cfefc7416d2ae52b0aad89e9b24da479ec010717b987c07f", size = 167111, upload-time = "2026-05-21T04:49:57.996Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/93fabbf2b505b610d019ec537c9dfa785a96920dcfc2ff8f57727aa54625/wrapt-2.2.0-cp312-cp312-win32.whl", hash = "sha256:9b58e2cdbcfe2278a031a12a7d73836d66bc1e9e65f97c63ea0a022f2f9f351b", size = 77867, upload-time = "2026-05-21T04:49:59.339Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/1564bcd9863dbf2cca3a687f53a6eeaaa08850e331948f1c4c7818401e88/wrapt-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:199abadf7dcceab4bdc5bfe356275a56b1cb429296e283da2fe90c20b09f8d07", size = 80827, upload-time = "2026-05-21T04:50:01.212Z" }, + { url = "https://files.pythonhosted.org/packages/67/04/354d2fd146936dccf55aced66a606f6e1665435e3119765acb00a8753eb3/wrapt-2.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8d40f1fb34d600b3eaf812941d6bcf313075728868cad1dafb7021e6a4e77983", size = 79094, upload-time = "2026-05-21T04:50:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bc/00d23a39b5f002dfa20f7441721bb44198e7c7b4a6b3f3d7b4ff88fe2dc6/wrapt-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:49c7ad697d6b13f322a1c3bb22a1c66827d5c0d303a4479e327210ee4d4ad179", size = 80816, upload-time = "2026-05-21T04:50:04.563Z" }, + { url = "https://files.pythonhosted.org/packages/14/ce/d0c5ecb47818be6d1717ea51eec1285f8d53777994fe44deaf9d7299f65c/wrapt-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:07dd562ebb774cad070eeedb93c7a29647979e30f0cfd1f5c9b9f803f687b6f4", size = 81346, upload-time = "2026-05-21T04:50:05.882Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/a68afc8f7b085bc34fa6e17a120a10b2a9e27579369c79fb40f31ba95d69/wrapt-2.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5b865e611c186d15366964e3d9500af504920ce7b92a211d61a83d2d3c42a508", size = 166769, upload-time = "2026-05-21T04:50:07.604Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/b3dadb67dd612223615438ce080be6bd1fee6de12ee16b2ff9725b3169b1/wrapt-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12331011cbf76b782d0beec7c7ed880f51454c127ab12012cfaecf56de01a80c", size = 166825, upload-time = "2026-05-21T04:50:09.129Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b3/9cb0277fb0f5c853aa6a91f384784e73db4c3db8ff0f405bc3f71d93daae/wrapt-2.2.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8ae3f4b50a3befa56da0f09d2b71a192454ce48e8887823dbc9228cdbb610f3", size = 157882, upload-time = "2026-05-21T04:50:10.954Z" }, + { url = "https://files.pythonhosted.org/packages/83/f6/e4295b9dadfd73d1db30fced3cdf1d083787d77857257998c5b9dda8b3d9/wrapt-2.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:370b2c36e8fee503c275e39b4588d74412cd0a7792f7f3a7b54c44c4d33d4884", size = 165791, upload-time = "2026-05-21T04:50:12.698Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/e66764a3aefb45a3a60ac76ea6878417a13f98e67f046f8e78b0a9ca6063/wrapt-2.2.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9040b15216e07ed68762e44ff231a460036e4bf3543f83988f669e7078847b2c", size = 156574, upload-time = "2026-05-21T04:50:14.28Z" }, + { url = "https://files.pythonhosted.org/packages/cf/90/e3355e82cc765a411283ff4335ab41034d4eab9f5226b3e5840bebcaaf96/wrapt-2.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8062689c0e6faf0c2532f566a492fb48ba60923c2cd6effda7cac9639dbdc1f3", size = 165943, upload-time = "2026-05-21T04:50:16.373Z" }, + { url = "https://files.pythonhosted.org/packages/e4/86/eda5a79813cd9ee86cd7275b9eac5338166886a5ffc9dcf881a3068d03a3/wrapt-2.2.0-cp313-cp313-win32.whl", hash = "sha256:a3848854af260eb4cc33602c685524fff7c8816f033325f750c7fc75c6deccf9", size = 77824, upload-time = "2026-05-21T04:50:18.241Z" }, + { url = "https://files.pythonhosted.org/packages/ba/de/1eadc4caa3797a33d231572435eed9116d24f56dc6c909c43b59092fbb37/wrapt-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:76b8111f8f5b8553c066caa26193921dea4185efecf1f9b38473054205137800", size = 80737, upload-time = "2026-05-21T04:50:20.038Z" }, + { url = "https://files.pythonhosted.org/packages/11/34/aeda6d757664a569a19d3e88e89f1c52134bbaa59b053bb316c69c71c459/wrapt-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:195db5b92deba6feb818732694ad478abb8a529d97a113cc256e5e49ee2dd80d", size = 79094, upload-time = "2026-05-21T04:50:21.784Z" }, + { url = "https://files.pythonhosted.org/packages/92/c7/3bfdcddd4c0281d104305e473953f1402bcae1898089656b6a9567a1e5cd/wrapt-2.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cf93c441b11c1f3ae2ccf1e8d876939b301b3234ec19f311ab0e7543a9d4427e", size = 82751, upload-time = "2026-05-21T04:50:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/37cc2bf299cfbf21f6bb7dfd0ba590e2d29f9e1fe6aa334a97395f4406dc/wrapt-2.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b208a5dd6f9da3d4b17aa2e4f8ca9c5dc6b9a2ed571fdef9ed465102487b445c", size = 83315, upload-time = "2026-05-21T04:50:25.085Z" }, + { url = "https://files.pythonhosted.org/packages/18/b0/bd4b4c51243a38009cc1c96f0503a535a7d8044636626bc7c545e766e73d/wrapt-2.2.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5248171d3cd33f12c144e7aa1222983cb6ab42651e985ce51fec400a876afbfd", size = 203752, upload-time = "2026-05-21T04:50:26.862Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b6/aee7c4fd7f19026d464ca7fd8a83efa5f3168ed33897ca0d1ec83bd15de4/wrapt-2.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f663528d6ea1804d279462671b2bf98a4c0d8a4a8dd319bb3ee0629b743387f", size = 209665, upload-time = "2026-05-21T04:50:28.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/91/be1181e580cd20a2584260285aa25fa9eb64a27a5921a431008910ea5d70/wrapt-2.2.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb240700f3b597c1d40d0932bfed2f4130fec2f02b8c2cb0bcdae45d321cb691", size = 194678, upload-time = "2026-05-21T04:50:30.307Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/cae7b5f26bf1385f562b7904db23b686e66a4f4f4b3496675531b1d0d968/wrapt-2.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1bf3ea62734b24c0241442d8b7684ef53a8de6cad0c2eba1e99fd2297b4a92e4", size = 205364, upload-time = "2026-05-21T04:50:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/81/d4/647312c3fcef95e6c65fd4c11efe4575cd021ac0074f3000cb066fc67c9e/wrapt-2.2.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ec257eedd8c3988cf76e351e949e3a56a61d90f4bb4e060de2ebfa6603df2a42", size = 192139, upload-time = "2026-05-21T04:50:33.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/c4/b40d8d176979b9397a4cfcc9eaafdd20697fc6e62293d70b1951d422b988/wrapt-2.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f58e1aa46c204171a2faa49b1ef2953edebb3913d270bb3bae7e970f254c9293", size = 199221, upload-time = "2026-05-21T04:50:36.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cf/71f00a6a0e9f5244c0bcc4e445d1087467d1c80e788637929bea0a1ea637/wrapt-2.2.0-cp313-cp313t-win32.whl", hash = "sha256:615be1d2b21450748e759bed7bf9ba8bc28307e91cb96b6e968f54f39e938ee5", size = 79438, upload-time = "2026-05-21T04:50:38.531Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/8219ee5e1491fdd880564af04a809eb8866481faff5cce6105174202667d/wrapt-2.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0680304db389599691bac06a2f9fb3f0ed06af59f132d35801a38cf6c321ab59", size = 83024, upload-time = "2026-05-21T04:50:39.892Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/ec61c19ea596299b0f0fca9f5ff82418a5152d933772bac90c61a4b06c30/wrapt-2.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:60bef9dc4348a76e9c2981ec4b06b779bac02556af4479030e6f62b18545b3cc", size = 80282, upload-time = "2026-05-21T04:50:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/11/b7/dd4278d51621fd5054f840744be1c830b37e9d7b9b22b5590eb69c5039a3/wrapt-2.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5c17982ccfece323bb297a195c9602ef407819199d8dbf99b8041770513fd68f", size = 80861, upload-time = "2026-05-21T04:50:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/65/ec/06efd37278eaee793521aee41091cb29fe20603dc5bd2f5cdc4e73fe9ce8/wrapt-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d2aab40474b6adae53d14d1f6a7785f4346a93c072adf1e69ca11a1b6afc789e", size = 81436, upload-time = "2026-05-21T04:50:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/21/95/46922f9415f109506f8bdfd903138dbde8a507a70ca02904b8dcffaac171/wrapt-2.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db48e2623a8aca63dfcfa7e574a5f3a9f760be1c464ee23f6387f70cc9112aa2", size = 166655, upload-time = "2026-05-21T04:50:45.951Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/601af72054c2166e11781a30b0fd6f7d500e9186351e73f8ff5d923afcee/wrapt-2.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f990f1b5c8ee4ff980bdef3f73f50728fd911b9ab8de8c43144e8019dcd845ff", size = 166257, upload-time = "2026-05-21T04:50:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/b2e96a62cd572f186eb94be906d4854dd301b20a3b30b648c8ddab11a2fb/wrapt-2.2.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c990d58100f9ebb8e7a20bd2e7bd3c60838be38c5bbccdd35041bc9f36dc0cea", size = 157694, upload-time = "2026-05-21T04:50:49.215Z" }, + { url = "https://files.pythonhosted.org/packages/72/f8/77fa31bda9344ca76d6a8eb6f5bd274aea1a7e24d6279b21fc2349d41fbe/wrapt-2.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:686f1798727bf4a708df015ca782b20abe99b3664e1ee9786b7712b0e2310586", size = 166036, upload-time = "2026-05-21T04:50:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/3e/73/118d00ad41f270128aa94a80b8150c5b720c18e06dc1a2291795c33839ec/wrapt-2.2.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b9733ef187cf05e774484ed2f703992a44429050f1cfea2e94dac543da78292", size = 156437, upload-time = "2026-05-21T04:50:52.415Z" }, + { url = "https://files.pythonhosted.org/packages/24/43/16017c26a1eeccbbf8f79f5172095bf9b0cb7183ac9bfc4a3c2c9fc37675/wrapt-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:231e2728ba04536821d2327ad2b3cb2c20cc79197fe5c30ddf71b12d95febe10", size = 165492, upload-time = "2026-05-21T04:50:54.16Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bd/d5d59f0a074e192f1cdafdeafca3d1aca25c3dd9172e0418fd04a912b864/wrapt-2.2.0-cp314-cp314-win32.whl", hash = "sha256:319720847afa6c58c32f84f9743bdcf34448ae56908c00f409764c627ff2c1fe", size = 78343, upload-time = "2026-05-21T04:50:56.028Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f5/c7fbbcbd8285f1999666115a793890a38e8b88744b8c3630059a0efa88bb/wrapt-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:628fbd908649611c8b9293e2e050231f1e230be152e7d38140e3b818ec6aade0", size = 81144, upload-time = "2026-05-21T04:50:57.538Z" }, + { url = "https://files.pythonhosted.org/packages/93/aa/152902a4b85cb55daad6e383a91ca5e23fd8d56132a4aa44987b7154f5e3/wrapt-2.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b4ce4240a3f095e77cfcc5aed6001bd63af13ea53c35ef496af1a5a972e7eaa9", size = 79573, upload-time = "2026-05-21T04:50:59.307Z" }, + { url = "https://files.pythonhosted.org/packages/af/fe/a25c3eee98417de1caf541c1b234bbc3a8b0ce4817b0c8934ca57bfe3e89/wrapt-2.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f0318a47d23c9407f4f94c06824662499e889ab8c192c1162e4f542a118fd700", size = 82844, upload-time = "2026-05-21T04:51:00.767Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/31060eb2f475b7798926f46c1779ec93329a48730cbeb8f9c0855162f97b/wrapt-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8a094508b7cd6e583378f3cf50f125814961660225bad88f4ecaa691e30b09e1", size = 83321, upload-time = "2026-05-21T04:51:02.253Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/62702f8bdaf509e444ec38bf142122db8c5ebbdfe6e2ca8e1dd7d43fb574/wrapt-2.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:115ff1501c11ac0e267c4afd6f6b3dd24b48afcc77b029e6062f71b12bce1d79", size = 203740, upload-time = "2026-05-21T04:51:03.8Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c6/c9ea3537ea759edcc856a32fc2d16abee41d7474f853bf00089058c0a33e/wrapt-2.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d4156fd35d0bdab58eac4a6854fbd053a59544fc57eb66e977b3c13c087a1c", size = 209671, upload-time = "2026-05-21T04:51:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/5925232cf614c23969b2267d954976e288993ef9e94a74eba4f26ad41232/wrapt-2.2.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b0aa81f4a3d0203ae8450eae5e794540afbf00a97dd0b81accbe5b4a5362cbb", size = 194717, upload-time = "2026-05-21T04:51:07.227Z" }, + { url = "https://files.pythonhosted.org/packages/10/95/b824ac1e5900f39f80d0d4e97cf59389b078d0fed3551f471911f9b46281/wrapt-2.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74b7949da2ffcd79869ac1e90946c14ce61a714269403a879ea9ed85a993c81f", size = 205335, upload-time = "2026-05-21T04:51:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/d4/58/623708a153bb1a519260bf61086c5f381196a7d505ac729f7979b0d1a957/wrapt-2.2.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c7af243871699358ebf34a770205bf2b61ccb17a0b003e8726d2028cc36ce364", size = 192170, upload-time = "2026-05-21T04:51:10.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/d771a75386676fe08086affe57b0f7cffafe528642ae5ebf95200811248e/wrapt-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb9d0c3f416e2c7c37498d1716fe323379da8b4e860da3d3818a6ec8fff7b7e5", size = 199200, upload-time = "2026-05-21T04:51:12.269Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/afd8991950e38f32c73008a3f2cd834cab32e338cc1997b7a39272a22cbc/wrapt-2.2.0-cp314-cp314t-win32.whl", hash = "sha256:4d5b485a6f617825fa7449f5025ebcdad9355acb328cb6d198ba225762219bc0", size = 80206, upload-time = "2026-05-21T04:51:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/d10901fb7686ec642e22d75d260f07ba6e05d28d5c83cb1efdc8d5c03e07/wrapt-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:cccce5c70a209eb385c82d063f332ed97fc02d1cf7bffb95b2e6995b5a9b8388", size = 83830, upload-time = "2026-05-21T04:51:15.341Z" }, + { url = "https://files.pythonhosted.org/packages/f3/11/d41fd5f17432703783f996fddc475d40baf20fe76f2c6dc217c2dd219b4c/wrapt-2.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9ad894d5dc5960ebd546a87a78160a8c645b99899e7e45a538436919bc9be5a6", size = 80711, upload-time = "2026-05-21T04:51:16.859Z" }, + { url = "https://files.pythonhosted.org/packages/33/19/713f33fcd8f7b0aa87c9d068b590dc1e86c51d5e329bf83dd91ee47fe872/wrapt-2.2.0-py3-none-any.whl", hash = "sha256:03b77d3ecab6c38e5da7a5709cee6899083d08fc1bcd648b4fa78b346fc66282", size = 60994, upload-time = "2026-05-21T04:51:37.606Z" }, +] From efb85440b7b445c41232391adfa50b2b7520d848 Mon Sep 17 00:00:00 2001 From: _ <50262751+hunzlahmalik@users.noreply.github.com> Date: Thu, 21 May 2026 13:22:31 +0500 Subject: [PATCH 2/9] Centralize endpoint URLs in _endpoints.py Pulled the 6 Platform Key Management path strings out of resources/ and test_clients.py into a single private module. Resources and tests now import named constants (PROVISION_KEY, LIST_KEYS, REVOKE_KEY, AVAILABLE_MODELS, GET_KEY_CONFIG, UPDATE_KEY_MODEL); a future API rename becomes a one-line edit instead of touching 5 files. The low-level client.request() escape hatch still accepts raw paths for endpoints not yet wrapped. Co-Authored-By: Claude Opus 4.7 --- src/askii/_endpoints.py | 24 ++++++++++++++++++++++++ src/askii/resources/keys.py | 27 +++++++++++++++++---------- src/askii/resources/models.py | 5 +++-- tests/unit/test_clients.py | 18 ++++++++++++------ 4 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 src/askii/_endpoints.py diff --git a/src/askii/_endpoints.py b/src/askii/_endpoints.py new file mode 100644 index 0000000..1b8b288 --- /dev/null +++ b/src/askii/_endpoints.py @@ -0,0 +1,24 @@ +"""Centralized URL paths for the Askii Platform API. + +Resources import paths from here so a future rename or version-prefix change is +a single-file edit. The low-level :meth:`askii.AsyncAskii.request` escape hatch +still accepts raw paths for endpoints not yet wrapped. +""" + +from __future__ import annotations + +PROVISION_KEY = "/platform/provision-key" +LIST_KEYS = "/platform/list-keys" +REVOKE_KEY = "/platform/revoke-key" +AVAILABLE_MODELS = "/platform/available-models" +GET_KEY_CONFIG = "/platform/get-key-config" +UPDATE_KEY_MODEL = "/platform/update-key-model" + +__all__ = [ + "PROVISION_KEY", + "LIST_KEYS", + "REVOKE_KEY", + "AVAILABLE_MODELS", + "GET_KEY_CONFIG", + "UPDATE_KEY_MODEL", +] diff --git a/src/askii/resources/keys.py b/src/askii/resources/keys.py index a72fac3..0bf1ed7 100644 --- a/src/askii/resources/keys.py +++ b/src/askii/resources/keys.py @@ -4,6 +4,13 @@ from typing import TYPE_CHECKING +from askii._endpoints import ( + GET_KEY_CONFIG, + LIST_KEYS, + PROVISION_KEY, + REVOKE_KEY, + UPDATE_KEY_MODEL, +) from askii.models import ( GetKeyConfigRequest, KeyConfig, @@ -71,7 +78,7 @@ async def provision( models=models, default_model=default_model, ) - data = await self._client._arequest("POST", "/platform/provision-key", body=_provision_body(req)) + data = await self._client._arequest("POST", PROVISION_KEY, body=_provision_body(req)) await self._client._ainvalidate(_RESOURCE) return ProvisionKeyResponse.model_validate(data) @@ -79,7 +86,7 @@ async def list(self, *, cache_ttl: float | None = None) -> ListKeysResponse: """List active keys for the authenticated user.""" data = await self._client._arequest( "POST", - "/platform/list-keys", + LIST_KEYS, body={}, cache_resource=_RESOURCE, cache_op="list", @@ -89,7 +96,7 @@ async def list(self, *, cache_ttl: float | None = None) -> ListKeysResponse: async def revoke(self, *, key: str) -> RevokeKeyResponse: """Revoke a key by ``sk-...`` value or ``key_alias``.""" - data = await self._client._arequest("POST", "/platform/revoke-key", body=_revoke_body(key)) + data = await self._client._arequest("POST", REVOKE_KEY, body=_revoke_body(key)) await self._client._ainvalidate(_RESOURCE) return RevokeKeyResponse.model_validate(data) @@ -97,7 +104,7 @@ async def get_config(self, *, key: str, cache_ttl: float | None = None) -> KeyCo """Return model configuration for one key.""" data = await self._client._arequest( "POST", - "/platform/get-key-config", + GET_KEY_CONFIG, body=_get_config_body(key), cache_resource=_RESOURCE, cache_op="get_config", @@ -116,7 +123,7 @@ async def update_model( """Update the model configuration for a key.""" data = await self._client._arequest( "POST", - "/platform/update-key-model", + UPDATE_KEY_MODEL, body=_update_body(key, models, default_model), ) await self._client._ainvalidate(_RESOURCE) @@ -145,7 +152,7 @@ def provision( models=models, default_model=default_model, ) - data = self._client._request("POST", "/platform/provision-key", body=_provision_body(req)) + data = self._client._request("POST", PROVISION_KEY, body=_provision_body(req)) self._client._invalidate(_RESOURCE) return ProvisionKeyResponse.model_validate(data) @@ -153,7 +160,7 @@ def list(self, *, cache_ttl: float | None = None) -> ListKeysResponse: """List active keys for the authenticated user.""" data = self._client._request( "POST", - "/platform/list-keys", + LIST_KEYS, body={}, cache_resource=_RESOURCE, cache_op="list", @@ -163,7 +170,7 @@ def list(self, *, cache_ttl: float | None = None) -> ListKeysResponse: def revoke(self, *, key: str) -> RevokeKeyResponse: """Revoke a key by ``sk-...`` value or ``key_alias``.""" - data = self._client._request("POST", "/platform/revoke-key", body=_revoke_body(key)) + data = self._client._request("POST", REVOKE_KEY, body=_revoke_body(key)) self._client._invalidate(_RESOURCE) return RevokeKeyResponse.model_validate(data) @@ -171,7 +178,7 @@ def get_config(self, *, key: str, cache_ttl: float | None = None) -> KeyConfig: """Return model configuration for one key.""" data = self._client._request( "POST", - "/platform/get-key-config", + GET_KEY_CONFIG, body=_get_config_body(key), cache_resource=_RESOURCE, cache_op="get_config", @@ -190,7 +197,7 @@ def update_model( """Update the model configuration for a key.""" data = self._client._request( "POST", - "/platform/update-key-model", + UPDATE_KEY_MODEL, body=_update_body(key, models, default_model), ) self._client._invalidate(_RESOURCE) diff --git a/src/askii/resources/models.py b/src/askii/resources/models.py index df0f8e5..c5b2881 100644 --- a/src/askii/resources/models.py +++ b/src/askii/resources/models.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING +from askii._endpoints import AVAILABLE_MODELS from askii.models import AvailableModelsResponse from askii.resources._base import _AsyncResource, _SyncResource @@ -22,7 +23,7 @@ async def list(self, *, cache_ttl: float | None = None) -> AvailableModelsRespon """List the LLM models available on the platform.""" data = await self._client._arequest( "POST", - "/platform/available-models", + AVAILABLE_MODELS, body={}, cache_resource=_RESOURCE, cache_op="list", @@ -38,7 +39,7 @@ def list(self, *, cache_ttl: float | None = None) -> AvailableModelsResponse: """List the LLM models available on the platform.""" data = self._client._request( "POST", - "/platform/available-models", + AVAILABLE_MODELS, body={}, cache_resource=_RESOURCE, cache_op="list", diff --git a/tests/unit/test_clients.py b/tests/unit/test_clients.py index b4bdbeb..207e10e 100644 --- a/tests/unit/test_clients.py +++ b/tests/unit/test_clients.py @@ -16,6 +16,12 @@ InMemoryCache, MemoryMode, ) +from askii._endpoints import ( + GET_KEY_CONFIG, + LIST_KEYS, + PROVISION_KEY, + UPDATE_KEY_MODEL, +) def _resp(status: int = 200, body: dict[str, Any] | None = None) -> httpx.Response: @@ -39,7 +45,7 @@ def handler(req: httpx.Request) -> httpx.Response: async def test_async_keys_provision_invokes_endpoint(config_factory: Callable[..., AskiiConfig]) -> None: cfg = config_factory() routes = { - "/platform/provision-key": { + PROVISION_KEY: { "api_key": "sk-real-secret-key-1234567890", "key_name": "key-1", "user_id": "user-1", @@ -88,8 +94,8 @@ async def test_async_keys_provision_invalidates_list_cache(config_factory: Calla cfg = config_factory(cache=cache) seen = {"n": 0} routes = { - "/platform/list-keys": {"user_id": "u", "keys": []}, - "/platform/provision-key": { + LIST_KEYS: {"user_id": "u", "keys": []}, + PROVISION_KEY: { "api_key": "sk-abcdefgh12345678", "key_name": "k", "user_id": "u", @@ -97,7 +103,7 @@ async def test_async_keys_provision_invalidates_list_cache(config_factory: Calla } def handler(req: httpx.Request) -> httpx.Response: - if req.url.path == "/platform/list-keys": + if req.url.path == LIST_KEYS: seen["n"] += 1 return _resp(200, routes[req.url.path]) @@ -229,10 +235,10 @@ def test_sync_keys_get_config_then_invalidate(config_factory: Callable[..., Aski seen = {"get_config": 0} def handler(req: httpx.Request) -> httpx.Response: - if req.url.path == "/platform/get-key-config": + if req.url.path == GET_KEY_CONFIG: seen["get_config"] += 1 return _resp(200, {"key_name": "k", "models": [], "memory_enabled": False}) - if req.url.path == "/platform/update-key-model": + if req.url.path == UPDATE_KEY_MODEL: return _resp(200, {"updated": True, "key_name": "k", "models": ["gpt-4o"]}) return _resp(404, {"detail": "?"}) From cfb5faddf7c55e7eb39f94741f62c28742636a3a Mon Sep 17 00:00:00 2001 From: _ <50262751+hunzlahmalik@users.noreply.github.com> Date: Thu, 21 May 2026 17:33:53 +0500 Subject: [PATCH 3/9] Fix mpass_token shadowing, Retry-After dates, mutating-POST retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 fixes: - mpass_token now merges last so a caller-supplied key in body cannot shadow the resolver's token (previous merge order let attackers/bugs override identity). - _parse_retry_after falls back to email.utils.parsedate_to_datetime when the value isn't delta-seconds, so HTTP-date form (RFC 7231 §7.1.3) is honored instead of being silently dropped. - Per-call idempotent flag on the transport, client.request escape hatch, and resource methods. keys.provision / keys.revoke / keys.update_model pass idempotent=False so a transient 5xx cannot double-apply the mutation. Read-only ops (keys.list, keys.get_config, models.list) keep the existing retry behavior. P1 hygiene: - CHANGELOG entries for URL centralization, P0 fixes, idempotent flag. - CI matrix and pyproject classifiers extended to Python 3.14. - New tests/unit/test_endpoints.py pins the _endpoints contract. - ASKII_CA_BUNDLE / ASKII_VERIFY recognized by AskiiConfig.from_env; README env-var table updated. 177 tests pass (was 152); coverage stays above the 90% gate. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 14 ++++- README.md | 26 ++++++++- pyproject.toml | 1 + src/askii/_client/async_client.py | 11 +++- src/askii/_client/sync_client.py | 11 +++- src/askii/_config.py | 8 +++ src/askii/_errors.py | 16 +++++ src/askii/_transport.py | 26 +++++++-- src/askii/resources/keys.py | 10 ++-- tests/unit/test_clients.py | 93 ++++++++++++++++++++++++++++++ tests/unit/test_config.py | 27 +++++++++ tests/unit/test_endpoints.py | 37 ++++++++++++ tests/unit/test_errors.py | 41 +++++++++++++ tests/unit/test_transport_async.py | 34 +++++++++++ tests/unit/test_transport_sync.py | 16 +++++ 16 files changed, 357 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_endpoints.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 340fb45..e09589f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index d87b3f1..639e81a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pydantic v2 request/response models; `SecretStr`-wrapped provisioned `api_key`. - Typed exception hierarchy with `FieldError` mapping for 422 responses. - Pluggable `Cache` Protocol with `InMemoryCache` and optional `RedisCache` (`[redis]` extra). -- tenacity-based retries with exponential backoff + jitter; honors `Retry-After`. +- tenacity-based retries with exponential backoff + jitter; honors `Retry-After` (now also accepts HTTP-date form). +- Per-call `idempotent` flag on `client.request()` / transport. Mutating resource methods (`keys.provision`, `keys.revoke`, `keys.update_model`) opt out of the retry policy so a transient 5xx cannot double-apply a mutation. - Lifecycle hooks (`on_request`, `on_response`, `on_retry`, `on_cache_hit`, `on_cache_miss`, `on_error`). - Opt-in JSON logging with secret redaction and correlation-ID propagation. - Bundled `askii` CLI (typer + rich) covering all Platform Key Management endpoints. +- CI matrix now covers Python 3.10 through 3.14. +- `ASKII_VERIFY` and `ASKII_CA_BUNDLE` environment variables wire TLS verification into `AskiiConfig.from_env`. + +### Changed + +- Centralize Platform Key Management URL paths in `askii._endpoints` so upstream renames or version-prefix changes are a one-line edit. + +### Fixed + +- `mpass_token` injection now wins over any caller-supplied `mpass_token` in `body=…` (previously, a caller's key could shadow the resolver's token). +- `Retry-After` headers in HTTP-date form (RFC 7231 §7.1.3) are now parsed correctly; previously they fell back to exponential backoff and ignored the server's wait hint. [Unreleased]: https://github.com/Pressingly/askii-python/compare/v0.0.0...HEAD diff --git a/README.md b/README.md index 58d7bb2..3d2dc3a 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,12 @@ Recognized environment variables: | `ASKII_TOKEN` | `token` | — | | `ASKII_TIMEOUT_SECONDS` | `timeout` | `30.0` | | `ASKII_MAX_RETRIES` | `max_retries` | `3` | +| `ASKII_CA_BUNDLE` | `verify` (path) | system trust store | +| `ASKII_VERIFY` | `verify` (`0/false/no/off` → `False`) | `True` | + +`ASKII_CA_BUNDLE` wins over `ASKII_VERIFY` when both are set. Disabling TLS +verification is intended for local-dev / mkcert setups — don't ship it to +prod. --- @@ -185,8 +191,24 @@ except AskiiValidationError as exc: ``` 5xx / 429 / connection / timeout errors are retried by the built-in tenacity -policy (max 3 attempts, exponential backoff with jitter, honors `Retry-After`). -4xx errors are not retried. +policy (max 3 attempts, exponential backoff with jitter, honors `Retry-After` +in both delta-seconds and HTTP-date form). 4xx errors are not retried. + +### Idempotent vs. mutating calls + +Retrying a non-idempotent POST is unsafe — a transient 5xx may mean the +upstream did execute the mutation but failed to respond, and a blind retry +would double-apply it. The SDK marks the mutating resource methods — +`keys.provision`, `keys.revoke`, `keys.update_model` — with +`idempotent=False`, which skips the retry policy for those calls (they run +once and raise on the first failure). Read-only calls (`keys.list`, +`keys.get_config`, `models.list`) and the low-level `client.request()` +escape hatch default to `idempotent=True`. Override the flag on the escape +hatch if you're calling a mutating endpoint that isn't yet wrapped: + +```python +await client.request("POST", "/platform/new-mutation", body={...}, idempotent=False) +``` --- diff --git a/pyproject.toml b/pyproject.toml index 7a1a92b..c291843 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Typing :: Typed", ] diff --git a/src/askii/_client/async_client.py b/src/askii/_client/async_client.py index 1801259..6bb7cff 100644 --- a/src/askii/_client/async_client.py +++ b/src/askii/_client/async_client.py @@ -56,6 +56,7 @@ async def request( path: str, *, body: dict[str, Any] | None = None, + idempotent: bool = True, cache_ttl: float | None = None, cache_resource: str | None = None, cache_op: str | None = None, @@ -64,12 +65,15 @@ async def request( """Send an arbitrary request to the Askii API. The ``mpass_token`` is injected for you. Provide ``cache_resource`` + - ``cache_op`` + ``cache_ttl`` if you want the response cached. + ``cache_op`` + ``cache_ttl`` if you want the response cached. Pass + ``idempotent=False`` for mutating endpoints — the SDK then skips the + retry policy so a transient 5xx cannot double-apply the mutation. """ return await self._arequest( method, path, body=body or {}, + idempotent=idempotent, cache_resource=cache_resource, cache_op=cache_op, cache_args=cache_args, @@ -86,13 +90,15 @@ async def _arequest( path: str, *, body: dict[str, Any], + idempotent: bool = True, cache_resource: str | None = None, cache_op: str | None = None, cache_args: dict[str, Any] | None = None, cache_ttl: float | None = None, ) -> dict[str, Any]: token = await self._resolver() - body_with_token: dict[str, Any] = {"mpass_token": token, **body} + # mpass_token is set last so it cannot be shadowed by a caller-supplied key in body. + body_with_token: dict[str, Any] = {**body, "mpass_token": token} cache_key: str | None = None if cache_resource and cache_op and cache_ttl and cache_ttl > 0: cache_key = build_cache_key(token, cache_resource, cache_op, cache_args) @@ -100,6 +106,7 @@ async def _arequest( method, path, body=body_with_token, + idempotent=idempotent, cache_key=cache_key, cache_ttl=cache_ttl, ) diff --git a/src/askii/_client/sync_client.py b/src/askii/_client/sync_client.py index 9857e9e..b8b8bc2 100644 --- a/src/askii/_client/sync_client.py +++ b/src/askii/_client/sync_client.py @@ -51,6 +51,7 @@ def request( path: str, *, body: dict[str, Any] | None = None, + idempotent: bool = True, cache_ttl: float | None = None, cache_resource: str | None = None, cache_op: str | None = None, @@ -59,12 +60,15 @@ def request( """Send an arbitrary request to the Askii API. The ``mpass_token`` is injected for you. Provide ``cache_resource`` + - ``cache_op`` + ``cache_ttl`` if you want the response cached. + ``cache_op`` + ``cache_ttl`` if you want the response cached. Pass + ``idempotent=False`` for mutating endpoints — the SDK then skips the + retry policy so a transient 5xx cannot double-apply the mutation. """ return self._request( method, path, body=body or {}, + idempotent=idempotent, cache_resource=cache_resource, cache_op=cache_op, cache_args=cache_args, @@ -77,13 +81,15 @@ def _request( path: str, *, body: dict[str, Any], + idempotent: bool = True, cache_resource: str | None = None, cache_op: str | None = None, cache_args: dict[str, Any] | None = None, cache_ttl: float | None = None, ) -> dict[str, Any]: token = self._resolver() - body_with_token: dict[str, Any] = {"mpass_token": token, **body} + # mpass_token is set last so it cannot be shadowed by a caller-supplied key in body. + body_with_token: dict[str, Any] = {**body, "mpass_token": token} cache_key: str | None = None if cache_resource and cache_op and cache_ttl and cache_ttl > 0: cache_key = build_cache_key(token, cache_resource, cache_op, cache_args) @@ -91,6 +97,7 @@ def _request( method, path, body=body_with_token, + idempotent=idempotent, cache_key=cache_key, cache_ttl=cache_ttl, ) diff --git a/src/askii/_config.py b/src/askii/_config.py index 65b2646..bfdbb15 100644 --- a/src/askii/_config.py +++ b/src/askii/_config.py @@ -61,6 +61,10 @@ def from_env(cls, **overrides: Any) -> AskiiConfig: - ``ASKII_TOKEN`` - ``ASKII_TIMEOUT_SECONDS`` - ``ASKII_MAX_RETRIES`` + - ``ASKII_CA_BUNDLE`` — path to a CA bundle for TLS verification. + - ``ASKII_VERIFY`` — set to ``0|false|no|off`` (case-insensitive) to + disable TLS verification entirely. ``ASKII_CA_BUNDLE`` wins if both + are set. Explicit kwargs always win over env vars. """ @@ -73,6 +77,10 @@ def from_env(cls, **overrides: Any) -> AskiiConfig: env_kwargs["timeout"] = float(raw) if (raw := os.getenv("ASKII_MAX_RETRIES")) is not None: env_kwargs["max_retries"] = int(raw) + if bundle := os.getenv("ASKII_CA_BUNDLE"): + env_kwargs["verify"] = bundle + elif (verify_raw := os.getenv("ASKII_VERIFY")) is not None: + env_kwargs["verify"] = verify_raw.strip().lower() not in {"0", "false", "no", "off"} env_kwargs.update(overrides) return cls(**env_kwargs) diff --git a/src/askii/_errors.py b/src/askii/_errors.py index 408b43f..7c1484d 100644 --- a/src/askii/_errors.py +++ b/src/askii/_errors.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -131,13 +133,27 @@ def _parse_field_errors(detail: Any) -> list[FieldError]: def _parse_retry_after(response: httpx.Response) -> float | None: + """Parse ``Retry-After`` as either delta-seconds or an HTTP-date. + + RFC 7231 §7.1.3 allows both forms. We return seconds-from-now; for an + HTTP-date in the past, we floor at 0 (the server is effectively saying + "retry immediately"). Garbage values yield ``None`` instead of raising. + """ raw = response.headers.get("retry-after") if not raw: return None try: return float(raw) except ValueError: + pass + try: + when = parsedate_to_datetime(raw) + except (TypeError, ValueError): return None + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + delta = (when - datetime.now(tz=timezone.utc)).total_seconds() + return max(0.0, delta) def _request_id_from(response: httpx.Response) -> str | None: diff --git a/src/askii/_transport.py b/src/askii/_transport.py index f4fe4fa..bafc046 100644 --- a/src/askii/_transport.py +++ b/src/askii/_transport.py @@ -102,10 +102,18 @@ async def arequest( path: str, *, body: dict[str, Any], + idempotent: bool = True, cache_key: str | None = None, cache_ttl: float | None = None, ) -> dict[str, Any]: - """Run an async request with cache + retry + hooks.""" + """Run an async request with cache + retry + hooks. + + When ``idempotent`` is ``False`` (mutating ops), the retry wrapper is + skipped entirely — a single attempt, errors propagate immediately. We + cannot safely retry a non-idempotent POST: a transient 5xx may mean + the upstream did execute the mutation but failed to respond, and a + retry would double-apply. + """ if self._sync or self._client_async is None: raise RuntimeError("HTTPTransport was constructed with sync=True") @@ -118,7 +126,10 @@ async def arequest( correlation_token, correlation_id = self._ensure_correlation_id() try: - payload = await self._arun_with_retries(method, path, body, correlation_id) + if idempotent: + payload = await self._arun_with_retries(method, path, body, correlation_id) + else: + payload = await self._asend(method, path, body, correlation_id, attempt=1) except BaseException as exc: self._hooks.error(exc) raise @@ -180,10 +191,14 @@ def request( path: str, *, body: dict[str, Any], + idempotent: bool = True, cache_key: str | None = None, cache_ttl: float | None = None, ) -> dict[str, Any]: - """Run a sync request with cache + retry + hooks.""" + """Run a sync request with cache + retry + hooks. + + See :meth:`arequest` for ``idempotent`` semantics. + """ if not self._sync or self._client_sync is None: raise RuntimeError("HTTPTransport was constructed with sync=False") @@ -196,7 +211,10 @@ def request( correlation_token, correlation_id = self._ensure_correlation_id() try: - payload = self._run_with_retries(method, path, body, correlation_id) + if idempotent: + payload = self._run_with_retries(method, path, body, correlation_id) + else: + payload = self._send(method, path, body, correlation_id, attempt=1) except BaseException as exc: self._hooks.error(exc) raise diff --git a/src/askii/resources/keys.py b/src/askii/resources/keys.py index 0bf1ed7..2bc4bc4 100644 --- a/src/askii/resources/keys.py +++ b/src/askii/resources/keys.py @@ -78,7 +78,7 @@ async def provision( models=models, default_model=default_model, ) - data = await self._client._arequest("POST", PROVISION_KEY, body=_provision_body(req)) + data = await self._client._arequest("POST", PROVISION_KEY, body=_provision_body(req), idempotent=False) await self._client._ainvalidate(_RESOURCE) return ProvisionKeyResponse.model_validate(data) @@ -96,7 +96,7 @@ async def list(self, *, cache_ttl: float | None = None) -> ListKeysResponse: async def revoke(self, *, key: str) -> RevokeKeyResponse: """Revoke a key by ``sk-...`` value or ``key_alias``.""" - data = await self._client._arequest("POST", REVOKE_KEY, body=_revoke_body(key)) + data = await self._client._arequest("POST", REVOKE_KEY, body=_revoke_body(key), idempotent=False) await self._client._ainvalidate(_RESOURCE) return RevokeKeyResponse.model_validate(data) @@ -125,6 +125,7 @@ async def update_model( "POST", UPDATE_KEY_MODEL, body=_update_body(key, models, default_model), + idempotent=False, ) await self._client._ainvalidate(_RESOURCE) return UpdateKeyModelResponse.model_validate(data) @@ -152,7 +153,7 @@ def provision( models=models, default_model=default_model, ) - data = self._client._request("POST", PROVISION_KEY, body=_provision_body(req)) + data = self._client._request("POST", PROVISION_KEY, body=_provision_body(req), idempotent=False) self._client._invalidate(_RESOURCE) return ProvisionKeyResponse.model_validate(data) @@ -170,7 +171,7 @@ def list(self, *, cache_ttl: float | None = None) -> ListKeysResponse: def revoke(self, *, key: str) -> RevokeKeyResponse: """Revoke a key by ``sk-...`` value or ``key_alias``.""" - data = self._client._request("POST", REVOKE_KEY, body=_revoke_body(key)) + data = self._client._request("POST", REVOKE_KEY, body=_revoke_body(key), idempotent=False) self._client._invalidate(_RESOURCE) return RevokeKeyResponse.model_validate(data) @@ -199,6 +200,7 @@ def update_model( "POST", UPDATE_KEY_MODEL, body=_update_body(key, models, default_model), + idempotent=False, ) self._client._invalidate(_RESOURCE) return UpdateKeyModelResponse.model_validate(data) diff --git a/tests/unit/test_clients.py b/tests/unit/test_clients.py index 207e10e..16679c8 100644 --- a/tests/unit/test_clients.py +++ b/tests/unit/test_clients.py @@ -139,6 +139,75 @@ def handler(req: httpx.Request) -> httpx.Response: await client.aclose() +async def test_async_caller_cannot_shadow_mpass_token(config_factory: Callable[..., AskiiConfig]) -> None: + """A caller-supplied ``mpass_token`` in body must lose to the resolver's token.""" + cfg = config_factory() + recorded: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + recorded.append(req) + return _resp(200, {"ok": True}) + + client = AsyncAskii( + token="resolver-jwt", + config=cfg, + http_client=httpx.AsyncClient(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) + await client.request( + "POST", + "/platform/future-endpoint", + body={"mpass_token": "ATTACKER-JWT", "x": 1}, + ) + sent = json.loads(recorded[0].content) + assert sent["mpass_token"] == "resolver-jwt" + assert sent["x"] == 1 + await client.aclose() + + +async def test_async_keys_provision_does_not_retry_on_5xx(config_factory: Callable[..., AskiiConfig]) -> None: + """`keys.provision` is non-idempotent — a transient 5xx must raise without retrying.""" + cfg = config_factory(max_retries=3) + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(500, {"detail": "boom"}) + + client = AsyncAskii( + token="jwt-1", + config=cfg, + http_client=httpx.AsyncClient(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) + from askii import AskiiServerError + + with pytest.raises(AskiiServerError): + await client.keys.provision(key_alias="x") + assert seen["n"] == 1 + await client.aclose() + + +async def test_async_keys_list_still_retries_on_5xx(config_factory: Callable[..., AskiiConfig]) -> None: + """`keys.list` is idempotent — the existing retry policy still applies.""" + cfg = config_factory(max_retries=3) + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + if seen["n"] < 2: + return _resp(500, {"detail": "boom"}) + return _resp(200, {"user_id": "u", "keys": []}) + + client = AsyncAskii( + token="jwt-1", + config=cfg, + http_client=httpx.AsyncClient(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) + resp = await client.keys.list() + assert resp.user_id == "u" + assert seen["n"] == 2 + await client.aclose() + + async def test_async_models_list(config_factory: Callable[..., AskiiConfig]) -> None: cfg = config_factory() @@ -191,6 +260,30 @@ def handler(req: httpx.Request) -> httpx.Response: client.close() +def test_sync_caller_cannot_shadow_mpass_token(config_factory: Callable[..., AskiiConfig]) -> None: + """Same shadow-protection as the async path.""" + cfg = config_factory() + recorded: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + recorded.append(req) + return _resp(200, {"ok": True}) + + with Askii( + token="resolver-jwt", + config=cfg, + http_client=httpx.Client(base_url=cfg.base_url, transport=httpx.MockTransport(handler)), + ) as client: + client.request( + "POST", + "/platform/anything", + body={"mpass_token": "ATTACKER-JWT", "x": 9}, + ) + sent = json.loads(recorded[0].content) + assert sent["mpass_token"] == "resolver-jwt" + assert sent["x"] == 9 + + def test_sync_request_escape_hatch_injects_token(config_factory: Callable[..., AskiiConfig]) -> None: cfg = config_factory() recorded: list[httpx.Request] = [] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index c43aac5..c3bb77b 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -56,3 +56,30 @@ def test_user_agent_includes_python_version() -> None: cfg = AskiiConfig() assert platform.python_version() in cfg.user_agent + + +def test_from_env_reads_ca_bundle(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ASKII_CA_BUNDLE", "/etc/ssl/certs/askii-ca.pem") + cfg = AskiiConfig.from_env() + assert cfg.verify == "/etc/ssl/certs/askii-ca.pem" + + +@pytest.mark.parametrize("raw", ["0", "false", "False", "NO", "off", "Off"]) +def test_from_env_disables_verify(monkeypatch: pytest.MonkeyPatch, raw: str) -> None: + monkeypatch.setenv("ASKII_VERIFY", raw) + cfg = AskiiConfig.from_env() + assert cfg.verify is False + + +@pytest.mark.parametrize("raw", ["1", "true", "yes", "on"]) +def test_from_env_enables_verify_explicitly(monkeypatch: pytest.MonkeyPatch, raw: str) -> None: + monkeypatch.setenv("ASKII_VERIFY", raw) + cfg = AskiiConfig.from_env() + assert cfg.verify is True + + +def test_from_env_ca_bundle_wins_over_verify_flag(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ASKII_VERIFY", "false") + monkeypatch.setenv("ASKII_CA_BUNDLE", "/etc/ssl/certs/askii-ca.pem") + cfg = AskiiConfig.from_env() + assert cfg.verify == "/etc/ssl/certs/askii-ca.pem" diff --git a/tests/unit/test_endpoints.py b/tests/unit/test_endpoints.py new file mode 100644 index 0000000..f134a02 --- /dev/null +++ b/tests/unit/test_endpoints.py @@ -0,0 +1,37 @@ +"""Contract test for the centralized endpoint constants. + +If anyone copy-pastes a constant by mistake, or deletes one that resources still +import, this test fails before pytest ever exercises the resource code. +""" + +from __future__ import annotations + +from askii import _endpoints + + +def test_all_endpoints_are_non_empty_platform_paths() -> None: + exported = _endpoints.__all__ + assert exported, "_endpoints.__all__ must list every public constant" + for name in exported: + value = getattr(_endpoints, name) + assert isinstance(value, str), f"{name} must be a str, got {type(value).__name__}" + assert value, f"{name} must not be empty" + assert value.startswith("/platform/"), f"{name}={value!r} must start with /platform/" + + +def test_endpoint_constants_are_unique() -> None: + values = [getattr(_endpoints, name) for name in _endpoints.__all__] + assert len(values) == len(set(values)), f"duplicate endpoint paths in _endpoints: {values}" + + +def test_expected_endpoints_present() -> None: + """Every Platform Key Management endpoint the SDK wraps today is exported.""" + expected = { + "PROVISION_KEY", + "LIST_KEYS", + "REVOKE_KEY", + "AVAILABLE_MODELS", + "GET_KEY_CONFIG", + "UPDATE_KEY_MODEL", + } + assert expected.issubset(set(_endpoints.__all__)) diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 2f5d084..f929f78 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -88,6 +88,47 @@ def test_429_with_invalid_retry_after_is_none() -> None: assert err.retry_after is None +def test_429_with_http_date_retry_after(monkeypatch: pytest.MonkeyPatch) -> None: + """``Retry-After`` may also be an HTTP-date (RFC 7231 §7.1.3).""" + from datetime import datetime, timezone + + fixed_now = datetime(2026, 10, 21, 7, 28, 0, tzinfo=timezone.utc) + + class _Clock: + @staticmethod + def now(tz: object = None) -> datetime: + return fixed_now + + monkeypatch.setattr("askii._errors.datetime", _Clock, raising=False) + # 30 seconds in the future + err = map_response_to_error(_make_response(429, {"detail": "x"}, {"retry-after": "Wed, 21 Oct 2026 07:28:30 GMT"})) + assert isinstance(err, AskiiRateLimitError) + assert err.retry_after is not None + assert 29.0 <= err.retry_after <= 31.0 + + +def test_429_with_past_http_date_retry_after_is_clamped_to_zero(monkeypatch: pytest.MonkeyPatch) -> None: + from datetime import datetime, timezone + + fixed_now = datetime(2026, 10, 21, 7, 28, 0, tzinfo=timezone.utc) + + class _Clock: + @staticmethod + def now(tz: object = None) -> datetime: + return fixed_now + + monkeypatch.setattr("askii._errors.datetime", _Clock, raising=False) + err = map_response_to_error(_make_response(429, {"detail": "x"}, {"retry-after": "Tue, 20 Oct 2026 07:28:00 GMT"})) + assert isinstance(err, AskiiRateLimitError) + assert err.retry_after == 0.0 + + +def test_429_with_unparseable_retry_after_falls_through_to_none() -> None: + err = map_response_to_error(_make_response(429, {"detail": "x"}, {"retry-after": "not a date"})) + assert isinstance(err, AskiiRateLimitError) + assert err.retry_after is None + + def test_maps_500_to_server_error() -> None: err = map_response_to_error(_make_response(500, {"detail": "boom"})) assert isinstance(err, AskiiServerError) diff --git a/tests/unit/test_transport_async.py b/tests/unit/test_transport_async.py index e0ba0d9..407f25b 100644 --- a/tests/unit/test_transport_async.py +++ b/tests/unit/test_transport_async.py @@ -84,6 +84,40 @@ def handler(req: httpx.Request) -> httpx.Response: await transport.aclose() +async def test_non_idempotent_call_skips_retries(config_factory: Callable[..., AskiiConfig]) -> None: + """`idempotent=False` must NOT retry on 5xx — the mutation may already have applied.""" + cfg = config_factory(max_retries=3) + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(500, {"detail": "boom"}) + + transport = _transport(cfg, handler) + from askii import AskiiServerError + + with pytest.raises(AskiiServerError): + await transport.arequest("POST", "/x", body={}, idempotent=False) + assert seen["n"] == 1 + await transport.aclose() + + +async def test_non_idempotent_call_still_succeeds_on_2xx(config_factory: Callable[..., AskiiConfig]) -> None: + """Happy-path non-idempotent calls return as normal.""" + cfg = config_factory(max_retries=3) + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(200, {"ok": True}) + + transport = _transport(cfg, handler) + result = await transport.arequest("POST", "/x", body={}, idempotent=False) + assert result == {"ok": True} + assert seen["n"] == 1 + await transport.aclose() + + async def test_gives_up_after_max_attempts(config_factory: Callable[..., AskiiConfig]) -> None: cfg = config_factory(max_retries=2) seen = {"n": 0} diff --git a/tests/unit/test_transport_sync.py b/tests/unit/test_transport_sync.py index 6619905..d8b6587 100644 --- a/tests/unit/test_transport_sync.py +++ b/tests/unit/test_transport_sync.py @@ -101,6 +101,22 @@ def test_sync_method_raises_on_async_transport_object(config_factory: Callable[. transport.request("POST", "/x", body={}) +def test_sync_non_idempotent_skips_retries(config_factory: Callable[..., AskiiConfig]) -> None: + """`idempotent=False` must NOT retry on 5xx in the sync transport either.""" + cfg = config_factory(max_retries=3) + seen = {"n": 0} + + def handler(req: httpx.Request) -> httpx.Response: + seen["n"] += 1 + return _resp(500, {"detail": "boom"}) + + transport = _transport(cfg, handler) + with pytest.raises(AskiiServerError): + transport.request("POST", "/x", body={}, idempotent=False) + assert seen["n"] == 1 + transport.close() + + def test_sync_returns_500_after_retries(config_factory: Callable[..., AskiiConfig]) -> None: def handler(req: httpx.Request) -> httpx.Response: return _resp(500, {"detail": "boom"}) From 9a3a389e36750e6e6c20b1ea0e8b249fbaad1a62 Mon Sep 17 00:00:00 2001 From: _ <50262751+hunzlahmalik@users.noreply.github.com> Date: Thu, 21 May 2026 17:43:00 +0500 Subject: [PATCH 4/9] Update CLAUDE.md for _endpoints, idempotent flag, env vars, baseline Brings the agent-guidance doc in line with what's actually on dev now: the centralized endpoint constants, the mpass_token merge-order invariant, the idempotent=False policy for mutating POSTs, the Retry-After HTTP-date support, and the new ASKII_CA_BUNDLE / ASKII_VERIFY env vars. Refreshes the verification baseline (177 tests, Python 3.10-3.14 matrix) and adds matching "don't do this" notes to keep future edits from regressing the invariants. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index caf35d9..c6eb150 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ live; consumers depend on `askii` and use its high-level resources. src/askii/ ├── __init__.py # public re-exports — keep this surface intentional ├── _config.py # AskiiConfig frozen dataclass + from_env() +├── _endpoints.py # centralized "/platform/..." path constants ├── _transport.py # HTTPTransport — sync + async, cache + retry + hooks ├── _retry.py # tenacity policy (5xx / 429 / network), honors Retry-After ├── _hooks.py # Hooks dataclass + RequestEvent/ResponseEvent/RetryEvent @@ -30,7 +31,7 @@ src/askii/ │ └── sync_client.py # Askii ├── resources/ │ ├── _base.py # _AsyncResource / _SyncResource bases -│ ├── keys.py # KeysResource / AsyncKeysResource — 5 of 6 endpoints +│ ├── keys.py # KeysResource / AsyncKeysResource — 5 Platform Key Management endpoints │ └── models.py # ModelsResource / AsyncModelsResource — available-models ├── models/ # Pydantic v2 request + response pairs per endpoint └── cli/_app.py # typer-based `askii` CLI @@ -41,12 +42,27 @@ src/askii/ * **Passthrough auth.** The library does not acquire Cognito tokens. Callers pass an mPass JWT (str, sync callable, or async callable). Tokens are resolved per request. +* **`mpass_token` always wins over caller body.** Client `_arequest` / + `_request` merge in the order `{**body, "mpass_token": token}` so a buggy + or malicious caller passing `body={"mpass_token": ...}` can't shadow the + resolver. There is a regression test in `test_clients.py` — don't flip the + merge order back. +* **URL paths live in `_endpoints.py`.** Every resource method imports its + path from there (`PROVISION_KEY`, `LIST_KEYS`, etc.). Never hardcode a + `"/platform/..."` literal at a call site — a typo becomes a silent 404. + Tests pin against the same constants. The escape-hatch `client.request()` + is the one place raw strings are intentional. * **Sync + async siblings.** `Askii` and `AsyncAskii` share the same transport engine via `HTTPTransport(sync=True|False)`. Both classes mirror each other's surface. * **Resource-oriented + escape hatch.** New endpoints get a resource method; ad-hoc calls go through `client.request(method, path, body=…)` which still - injects `mpass_token`. + injects `mpass_token` and accepts an `idempotent=…` flag. +* **`idempotent=False` for mutating POSTs.** Retrying a non-idempotent POST + is unsafe (a 5xx may mean the upstream did execute the mutation but failed + to respond). `keys.provision`, `keys.revoke`, `keys.update_model` pass + `idempotent=False` to skip the retry policy; read-only ops keep the default + `True`. When wrapping a new mutating endpoint, set `idempotent=False`. * **Cache off by default.** `AskiiConfig` ships an `InMemoryCache(default_ttl=0)` so no implicit caching happens. Callers opt in per call via `cache_ttl=…`. Cache keys hash the token (per-user namespacing); the raw token never appears @@ -56,18 +72,37 @@ src/askii/ as test failures, not silent breakage. * **Logging never force-configured.** The library uses `logging.getLogger("askii")` and inherits consumer config. `configure_logging()` is opt-in. +* **`Retry-After` accepts both forms.** RFC 7231 §7.1.3 allows delta-seconds + *or* HTTP-date. `_parse_retry_after` handles both; don't shortcut back to + seconds-only parsing. + +## Configuration + +`AskiiConfig.from_env()` reads: + +| Env var | Mapped to | Notes | +|---|---|---| +| `ASKII_BASE_URL` | `base_url` | Defaults to `https://api.askii.ai` | +| `ASKII_TOKEN` | `token` | Static-string source for the resolver | +| `ASKII_TIMEOUT_SECONDS` | `timeout` | float | +| `ASKII_MAX_RETRIES` | `max_retries` | int | +| `ASKII_CA_BUNDLE` | `verify` (path) | Wins over `ASKII_VERIFY` if both set | +| `ASKII_VERIFY` | `verify` (`0/false/no/off` → False) | For local mkcert / dev only | ## When extending ### Adding a new endpoint -1. Add Pydantic request + response models under `src/askii/models/.py`. -2. Add resource methods to `resources/.py` (both async and sync). -3. Wire cache key + TTL (or skip caching) and invalidation if it's a mutation. -4. Re-export from `models/__init__.py` and `src/askii/__init__.py`. -5. Add a unit test for the model round-trip and the resource method (sync + async). -6. Add a CLI command if it's user-facing. -7. Update `CHANGELOG.md` under `## [Unreleased]`. +1. Add the path constant to `src/askii/_endpoints.py` and its `__all__`. +2. Add Pydantic request + response models under `src/askii/models/.py`. +3. Add resource methods to `resources/.py` (both async and sync). + Import the path from `_endpoints`. Pass `idempotent=False` if it mutates. +4. Wire cache key + TTL (or skip caching) and invalidation if it's a mutation. +5. Re-export from `models/__init__.py` and `src/askii/__init__.py`. +6. Add unit tests: model round-trip, resource method (sync + async), and an + update to `tests/unit/test_endpoints.py` if you've added a constant. +7. Add a CLI command if it's user-facing. +8. Update `CHANGELOG.md` under `## [Unreleased]`. ### Adding a new resource namespace @@ -95,7 +130,8 @@ uv run pytest --cov=askii --cov-fail-under=90 -v uv build ``` -All four must pass before opening a PR. +All four must pass before opening a PR. Today's baseline: 177 tests pass, +coverage ≥ 90%. CI matrix covers Python 3.10 → 3.14 × Ubuntu / macOS. ## Things to avoid @@ -103,6 +139,8 @@ All four must pass before opening a PR. install-time cost. We're consumed by multiple Django/FastAPI services. * Don't add new top-level modules; keep internal helpers under `_*` so the public surface stays the explicit re-exports in `__init__.py`. +* Don't hardcode `"/platform/..."` URLs at call sites — use the constants in + `_endpoints.py`. * Don't widen `extra="forbid"` to `"allow"` on response models — the strict setting is the early-warning for upstream schema drift. * Don't add `from askii._cache.redis import RedisCache` to the public @@ -111,6 +149,10 @@ All four must pass before opening a PR. * Don't print or log tokens directly. The redaction filter catches most cases, but the right pattern is `SecretStr` on response models and never logging the raw resolved token. +* Don't change the `{**body, "mpass_token": token}` merge order without thinking + about the shadow-attack regression test. +* Don't retry mutating POSTs. New mutating endpoints must pass `idempotent=False` + to the transport; new escape-hatch calls that mutate should pass the same. ## Reference From 598db60dab7f44064e45475afa7bf7526c0e8656 Mon Sep 17 00:00:00 2001 From: _ <50262751+hunzlahmalik@users.noreply.github.com> Date: Thu, 21 May 2026 19:50:54 +0500 Subject: [PATCH 5/9] Promote CHANGELOG [Unreleased] to [0.1.0] for the v0.1.0 release Renames the section so the release workflow's notes extractor (which greps for `## []`) picks up the right block when the v0.1.0 tag fires. Adds a fresh empty `[Unreleased]` placeholder and rewrites the compare-link footer to point at the tag. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 639e81a..7ad942e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +_No changes yet._ + +## [0.1.0] - 2026-05-21 + ### Added - Initial release scaffolding for the Askii Python client. @@ -32,4 +36,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mpass_token` injection now wins over any caller-supplied `mpass_token` in `body=…` (previously, a caller's key could shadow the resolver's token). - `Retry-After` headers in HTTP-date form (RFC 7231 §7.1.3) are now parsed correctly; previously they fell back to exponential backoff and ignored the server's wait hint. -[Unreleased]: https://github.com/Pressingly/askii-python/compare/v0.0.0...HEAD +[Unreleased]: https://github.com/Pressingly/askii-python/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/Pressingly/askii-python/releases/tag/v0.1.0 From 1e63b0f60e2d37050a418cf0a8734a48568c51cd Mon Sep 17 00:00:00 2001 From: _ <50262751+hunzlahmalik@users.noreply.github.com> Date: Thu, 21 May 2026 19:59:03 +0500 Subject: [PATCH 6/9] Fix CLI JSON output: avoid ANSI codes on color-capable stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions runners surface as color-capable to Rich, so the `stdout.print_json(...)` calls were emitting ANSI escape codes around the JSON. `json.loads(result.stdout)` in the CLI test suite then choked on `\x1b[1m{...` with "Expecting value: line 1 column 1". The 4 affected tests (test_keys_list_json, test_keys_provision_unwraps_secret, test_keys_revoke_returns_json, test_models_list_json) passed locally on macOS where Rich didn't colorize, but failed on every CI matrix combination. Switch the three JSON-output call sites to stdlib `print( json.dumps(..., indent=2, default=str))` so the JSON is always plain — pipe-friendly for `jq`, parseable by tests, and indented for human readability. Tables and stderr error messages keep their Rich colorization since they're human-facing. Verified with `FORCE_COLOR=1 uv run pytest` (simulates the CI env); all 177 tests pass, coverage 90.32%. Co-Authored-By: Claude Opus 4.7 --- src/askii/cli/_app.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/askii/cli/_app.py b/src/askii/cli/_app.py index be9ffc7..623d539 100644 --- a/src/askii/cli/_app.py +++ b/src/askii/cli/_app.py @@ -102,13 +102,18 @@ def _to_dict(value: Any) -> Any: def _render(value: Any, output: OutputFmt, *, table: Table | None = None) -> None: + # JSON output goes through stdlib print() so it stays machine-parseable — + # Rich's print_json injects ANSI escapes whenever it deems stdout color- + # capable (which is true on GitHub Actions runners), and downstream tools + # piping our output through `jq` etc. would choke. Tables stay on Rich + # because they're human-facing. if output is OutputFmt.JSON: - stdout.print_json(json.dumps(_to_dict(value), default=str)) + print(json.dumps(_to_dict(value), indent=2, default=str)) return if table is not None: stdout.print(table) return - stdout.print_json(json.dumps(_to_dict(value), default=str)) + print(json.dumps(_to_dict(value), indent=2, default=str)) def _handle(func: Any) -> Any: @@ -220,7 +225,7 @@ def keys_provision( table.add_row(label, str(value)) stdout.print(table) return - stdout.print_json(json.dumps(payload, default=str)) + print(json.dumps(payload, indent=2, default=str)) @keys_app.command("revoke") From e60b47cb352c7c689d42dc29730e28fe582cf512 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 20:06:52 +0500 Subject: [PATCH 7/9] Bump actions/checkout from 4 to 6 (#4) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e09589f..949af9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v4 with: enable-cache: true @@ -33,7 +33,7 @@ jobs: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v4 with: enable-cache: true @@ -50,7 +50,7 @@ jobs: os: [ubuntu-latest, macos-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # hatch-vcs needs tags - uses: astral-sh/setup-uv@v4 @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest needs: [lint, typecheck, test] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: astral-sh/setup-uv@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5e8fa8..9e2c4f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: name: Build and publish GitHub Release runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 # hatch-vcs needs tags - uses: astral-sh/setup-uv@v4 From 0ad7bb5476b7b317c0e010546046fd65aec39411 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 20:07:44 +0500 Subject: [PATCH 8/9] Bump actions/upload-artifact from 4 to 7 (#3) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 949af9c..3554da0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: - run: uv run pytest --cov=askii --cov-report=term-missing --cov-report=xml --cov-fail-under=90 -v - name: Upload coverage if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-xml path: coverage.xml @@ -84,7 +84,7 @@ jobs: - name: Verify py.typed in wheel run: | python -m zipfile -l dist/*.whl | grep "askii/py.typed" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: dist path: dist/ From 8270dc80f422aee741c232744f002bab2fe338b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 20:10:58 +0500 Subject: [PATCH 9/9] Bump astral-sh/setup-uv from 4 to 7 (#2) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 4 to 7. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v4...v7) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3554da0..eadbb87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" @@ -53,7 +53,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 # hatch-vcs needs tags - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" @@ -76,7 +76,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e2c4f0..7e7886c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 # hatch-vcs needs tags - - uses: astral-sh/setup-uv@v4 + - uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "**/pyproject.toml"