From eed360b34a099135de45b10da4b2eacbf91f65ed Mon Sep 17 00:00:00 2001 From: Akshay Ram Date: Thu, 4 Jun 2026 14:42:51 +0700 Subject: [PATCH] feat: upgrade runtime recipes - Move app settings to TOML and wire Docker/startup defaults. - Replace execute tool with query plus learned recipe tools. - Add recipe contracts, validation, Redis store, polling, debug payloads. - Fix recipe param validation and REST list query params. - Refresh README, agent docs, and behavior tests. --- .env.example | 10 +- .github/workflows/test.yml | 12 +- .gitignore | 1 + AGENTS.md | 87 + CHANGELOG.md | 65 + CLAUDE.md | 150 +- CONTRIBUTING.md | 42 +- Dockerfile | 18 +- README.md | 319 ++-- api-agent.toml | 44 + api_agent/__main__.py | 34 +- api_agent/agent/graphql_agent.py | 492 ++---- api_agent/agent/model.py | 38 +- api_agent/agent/prompts.py | 15 +- api_agent/agent/rest_agent.py | 641 +++---- api_agent/agent/runtime.py | 314 ++++ api_agent/config.py | 148 +- api_agent/context.py | 138 +- api_agent/description.py | 463 +++++ api_agent/executor.py | 77 +- api_agent/graphql/client.py | 4 +- api_agent/graphql/schema_context.py | 105 ++ api_agent/middleware.py | 129 +- api_agent/query_response.py | 60 + api_agent/recipe/__init__.py | 65 +- api_agent/recipe/common.py | 543 ------ api_agent/recipe/contracts.py | 421 +++++ api_agent/recipe/execution.py | 347 ++++ api_agent/recipe/extractor.py | 344 ++-- api_agent/recipe/extractor_models.py | 92 + api_agent/recipe/extractor_prompts.py | 183 ++ api_agent/recipe/identity.py | 70 + api_agent/recipe/learning.py | 546 ++++++ api_agent/recipe/naming.py | 10 +- api_agent/recipe/runner.py | 127 +- api_agent/recipe/search.py | 121 ++ api_agent/recipe/state.py | 77 + api_agent/recipe/store.py | 351 ---- api_agent/recipe/templates.py | 41 + api_agent/recipe/tooling.py | 73 + api_agent/rest/client.py | 41 +- api_agent/rest/polling.py | 183 ++ api_agent/rest/schema_loader.py | 19 +- api_agent/store.py | 835 +++++++++ api_agent/tools/__init__.py | 14 +- api_agent/tools/execute.py | 108 -- api_agent/tools/query.py | 57 +- api_agent/tracing.py | 71 +- api_agent/utils/http_errors.py | 47 - pyproject.toml | 35 +- start.sh | 12 +- tests/conftest.py | 5 + tests/test_agent_queries.py | 103 ++ tests/test_agent_runtime.py | 102 ++ tests/test_app.py | 46 + tests/test_config.py | 147 ++ tests/test_context.py | 107 +- tests/test_description.py | 522 ++++++ tests/test_executor.py | 65 +- tests/test_graphql_client.py | 130 -- tests/test_http_errors.py | 36 - tests/test_individual_recipe_tools.py | 87 +- tests/test_middleware.py | 505 ++++-- tests/test_model_config.py | 29 + tests/test_poll_tool.py | 251 +-- tests/test_query_response.py | 186 +- tests/test_recipe_extraction.py | 1308 +++++++++++++- tests/test_recipe_runner.py | 56 +- tests/test_recipe_store.py | 1087 +++++++----- tests/test_recipe_tool_creation.py | 133 -- tests/test_recipe_tool_integration.py | 391 ----- tests/test_redis_recipe_store.py | 209 +++ tests/test_rest_client.py | 81 +- tests/test_rest_schema.py | 95 +- tests/test_schema_context.py | 148 +- tests/test_search_schema.py | 3 +- tests/test_tools.py | 31 + tests/test_tracing.py | 24 + uv.lock | 2277 +++++++++++++++---------- 79 files changed, 10905 insertions(+), 5498 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md mode change 100644 => 120000 CLAUDE.md create mode 100644 api-agent.toml create mode 100644 api_agent/agent/runtime.py create mode 100644 api_agent/description.py create mode 100644 api_agent/graphql/schema_context.py create mode 100644 api_agent/query_response.py delete mode 100644 api_agent/recipe/common.py create mode 100644 api_agent/recipe/contracts.py create mode 100644 api_agent/recipe/execution.py create mode 100644 api_agent/recipe/extractor_models.py create mode 100644 api_agent/recipe/extractor_prompts.py create mode 100644 api_agent/recipe/identity.py create mode 100644 api_agent/recipe/learning.py create mode 100644 api_agent/recipe/search.py create mode 100644 api_agent/recipe/state.py delete mode 100644 api_agent/recipe/store.py create mode 100644 api_agent/recipe/templates.py create mode 100644 api_agent/recipe/tooling.py create mode 100644 api_agent/rest/polling.py create mode 100644 api_agent/store.py delete mode 100644 api_agent/tools/execute.py delete mode 100644 api_agent/utils/http_errors.py create mode 100644 tests/conftest.py create mode 100644 tests/test_agent_queries.py create mode 100644 tests/test_agent_runtime.py create mode 100644 tests/test_app.py create mode 100644 tests/test_config.py create mode 100644 tests/test_description.py delete mode 100644 tests/test_graphql_client.py delete mode 100644 tests/test_http_errors.py create mode 100644 tests/test_model_config.py delete mode 100644 tests/test_recipe_tool_creation.py delete mode 100644 tests/test_recipe_tool_integration.py create mode 100644 tests/test_redis_recipe_store.py create mode 100644 tests/test_tools.py create mode 100644 tests/test_tracing.py diff --git a/.env.example b/.env.example index ef29478..5741e3e 100644 --- a/.env.example +++ b/.env.example @@ -4,11 +4,13 @@ OPENAI_API_KEY= # Optional: Custom LLM endpoint (defaults to OpenAI) # OPENAI_BASE_URL=https://api.openai.com/v1 -# Optional: Model to use (default: gpt-5.2) -# API_AGENT_MODEL_NAME=gpt-5.2 +# Optional: App config path (defaults to api-agent.toml locally) +# API_AGENT_CONFIG=api-agent.toml -# Optional: Server port (default: 3000) -# API_AGENT_PORT=3000 +# Optional: Server port override (defaults to [server].port in api-agent.toml) +# PORT=3000 + +# App settings like model live in api-agent.toml. # Optional: OpenTelemetry tracing endpoint # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b9e800..160f532 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,15 +29,21 @@ jobs: - name: Install dependencies run: uv sync --group dev - - name: Run tests - run: uv run pytest tests/ -v + - name: Check lockfile + run: uv lock --check - name: Run linter - run: uv run ruff check api_agent/ + run: uv run ruff check api_agent/ tests/ + + - name: Check formatting + run: uv run ruff format --check api_agent/ tests/ - name: Run type checker run: uv run ty check + - name: Run tests + run: uv run pytest tests/ -q + docker-build: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 3e277f1..2601448 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ wheels/ .vscode/ .cursor/ .claude/ +.codex/ *.swp # Testing/Caching diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..17a835b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,87 @@ +# AGENTS.md + +Universal agent guidance for this repo. + +## Why + +`api-agent` turns one GraphQL endpoint or REST OpenAPI spec into an MCP server. Users ask natural-language questions; the server introspects schema, calls the target API, stores tabular data in DuckDB, and runs SQL post-processing when the API cannot filter, rank, join, or aggregate directly. + +## What + +- `api_agent/__main__.py`: ASGI app, `/mcp`, `/health` +- `api_agent/config.py`: TOML-only app settings; `API_AGENT_CONFIG` selects file path +- `api_agent/context.py`: MCP request headers -> `RequestContext` +- `api_agent/tools/query.py`: public `{prefix}_query` tool +- `api_agent/agent/`: OpenAI Agents SDK GraphQL/REST flows +- `api_agent/rest/schema_loader.py`: OpenAPI 3.x + Swagger 2.0 schema loading +- `api_agent/rest/polling.py`: REST async polling tool +- `api_agent/recipe/`: learned recipe extraction, storage, matching, tool exposure +- `api_agent/query_response.py`: wrapped vs direct CSV response formatting +- `api_agent/executor.py`: DuckDB SQL execution and result shaping +- `tests/`: pytest coverage by module and behavior + +## How + +Use deterministic tools first. + +```bash +uv sync --group dev +uv run pytest tests/ -v +uv run pytest tests/test_foo.py::test_bar -v +uv run ruff check api_agent/ tests/ +uv run ruff format --check api_agent/ tests/ +uv run ruff format api_agent/ tests/ +uv run ty check +``` + +Before pushing, run the CI lint shape: + +```bash +uv sync --group dev && uv run ruff check api_agent/ && uv run ty check && uv run pytest tests/ -q +``` + +Run local server: + +```bash +uv run api-agent +# public: http://localhost:3000/mcp +``` + +Run local server with tracing/debug: + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ + uv run --no-sync opentelemetry-instrument api-agent +``` + +`start.sh` uses `opentelemetry-instrument` when an OTEL endpoint is set. Do not manually wire OpenAI Agents SDK trace exporters in app code; `openai_agents` auto-instrumentation should load `OpenAIAgentsInstrumentor`. + +Useful local override: + +```bash +API_AGENT_CONFIG=api-agent.toml +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +``` + +Put app levers in `api-agent.toml`. Keep `OPENAI_API_KEY` env-only. `OPENAI_BASE_URL` may be TOML or env override. Do not add other app env overrides. + +## Rules + +- Keep changes testable: functional core, imperative shell. +- Prefer existing patterns; read nearby code before editing. +- Update README/docs when behavior, config, deploy, or public tools change. +- Keep root guidance short; put task-specific detail in README or focused docs. +- Do not hand-roll formatting/lint checks; run `ruff`/`ty`. +- For runtime/debug, verify actual TOML path and HTTP behavior. +- For plans, end with unresolved questions, very concise. +- Be extremely concise in chat and commit messages. + +## Project Facts + +- Requests require `X-Target-URL` and `X-API-Type`. +- `X-API-Name` overrides tool prefix; explicit hyphens preserved. +- Public MCP exposes `{prefix}_query` plus learned `r_{slug}` tools. +- `_execute` is removed; query + recipe tools are the public surface. +- `X-Recipe-Learn-Rate: 1` forces successful-query recipe extraction. +- Recipe params are required top-level fields; examples are hints, not defaults. +- Unsafe REST writes are blocked unless allowed by `X-Allow-Unsafe-Paths`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..97206c2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +This file tracks notable service changes. It is not tied to package publishing, +GitHub Releases, or tag-based deployment. + +## Current + +### Added + +- Add generated downstream API descriptions with storage-backed caching. +- Add shared memory/Redis storage for recipes and generated descriptions. +- Add configurable description model and timeout. + +### Changed + +- Upgrade query and recipe runtime flow around shared GraphQL/REST execution. +- Move app configuration into `api-agent.toml`. +- Make recipe tools return CSV directly. +- Change learned recipe storage to the `public_contract` and `execution_plan` + format. Older learned recipes are not migrated automatically and should be + cleared, migrated, or relearned. +- Split recipe contracts, execution, extraction models, prompts, and tooling into + focused modules. + +### Removed + +- Remove the legacy `_execute` public tool surface. + +## OpenAPI compatibility - 2026-03-05 + +### Added + +- Add Swagger 2.0 schema loading. +- Add structured HTTP error details. + +## Documentation update - 2026-02-23 + +### Changed + +- Clarify unsafe REST path and polling header escaping examples. + +## Recipe tools - 2026-02-07 + +### Added + +- Add direct `r_{slug}` recipe tools. +- Add recipe execution for cached GraphQL and REST workflows. +- Add CSV response helpers for direct recipe output. + +## Recipe learning - 2026-01-27 + +### Added + +- Add learned recipe extraction, caching, and matching. +- Add recipe-aware query flow and recipe store tests. + +## Initial baseline - 2026-01-12 + +### Added + +- Initial release of API Agent as an MCP server for natural-language API queries. +- Add GraphQL and REST schema loading, request execution, and DuckDB SQL + post-processing. +- Add `{prefix}_query` tool with configurable target API headers. +- Add Docker, CI, README, CONTRIBUTING, and test coverage. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 661635d..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,149 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -**Setup:** -```bash -uv sync --group dev -``` - -**Run server:** -```bash -uv run api-agent # Local dev -# Or direct (no clone): uvx --from git+https://github.com/agoda-com/api-agent api-agent -# Server starts on http://localhost:3000/mcp -``` - -**Tests:** -```bash -uv run pytest tests/ -v # All tests -uv run pytest tests/test_foo.py -v # Single test file -uv run pytest tests/test_foo.py::test_bar -v # Single test -``` - -**Linting & Formatting:** -```bash -uv run ruff check api_agent/ -uv run ruff check --fix api_agent/ # Auto-fix -uv run ruff format api_agent/ # Format -uv run ty check # Type check -``` - -**Docker:** -```bash -docker build -t api-agent . -docker run -p 3000:3000 -e OPENAI_API_KEY="..." api-agent -``` - -## Architecture - -**MCP Server (FastMCP)** receives NL queries + headers → routes to **Agents** (OpenAI Agents SDK) → agents call target APIs + DuckDB for SQL processing. - -### Request Flow - -1. **Client** sends MCP request w/ headers (`X-Target-URL`, `X-API-Type`, `X-Target-Headers`) -2. **middleware.py**: `DynamicToolNamingMiddleware` transforms tool names per session (e.g., `_query` → `flights_api_query` based on URL) -3. **context.py**: Extracts `RequestContext` from headers -4. **tools/query.py**: Routes to GraphQL or REST agent -5. **agent/graphql_agent.py** or **agent/rest_agent.py**: - - Fetches schema (introspection or OpenAPI) - - Creates agent w/ dynamic tools (`graphql_query`/`rest_call`, `sql_query`, `search_schema`) - - Runs agent loop (max 30 turns) - - Returns results -6. **executor.py**: DuckDB integration for SQL post-processing - -### Key Modules - -- **api_agent/**: Main package - - **__main__.py**: Entry point, creates FastMCP app w/ middleware - - **config.py**: Settings via `pydantic-settings` (env vars w/ `API_AGENT_` prefix) - - **context.py**: Header parsing → `RequestContext`, tool name generation - - **middleware.py**: Dynamic tool naming per session - - **tracing.py**: OpenTelemetry tracing via OTLP (uses [arize-otel](https://github.com/Arize-ai/openinference) for convenience, works with [Arize Phoenix](https://docs.arize.com/phoenix), Jaeger, Zipkin, Grafana Tempo, etc.) - -- **api_agent/tools/**: MCP tool implementations - - **query.py**: `_query` tool (NL → agent) - - **execute.py**: `_execute` tool (direct GraphQL/REST call) - -- **api_agent/agent/**: Agent logic (OpenAI Agents SDK) - - **graphql_agent.py**: GraphQL agent w/ introspection, query building, SQL - - **rest_agent.py**: REST agent w/ OpenAPI parsing, polling support - - **prompts.py**: Shared system prompt fragments - - **model.py**: LLM config (OpenAI-compatible) - - **progress.py**: Turn tracking - - **schema_search.py**: Grep-like schema search tool - - **contextvar_utils.py**: Safe ContextVar access helpers - -- **api_agent/recipe/**: Parameterized pipeline caching - - **store.py**: `RecipeStore` (LRU in-memory cache, thread-safe) - - **extractor.py**: Extract reusable recipes from agent runs - - **runner.py**: Execute recipes outside agent context (for MCP tools) - - **common.py**: Recipe validation, execution, parameter binding - - **naming.py**: Tool name sanitization - -- **api_agent/utils/**: Shared utilities - - **csv.py**: CSV conversion via DuckDB (for recipe `return_directly` output) - - **http_errors.py**: HTTP error response extraction (used by both clients) - -- **api_agent/graphql/**: GraphQL client (httpx) -- **api_agent/rest/**: REST client (httpx) + OpenAPI loader (supports OpenAPI 3.x and Swagger 2.0) -- **api_agent/executor.py**: DuckDB SQL execution, table extraction, context truncation - -### Context Management - -All outputs capped at ~32k chars (`MAX_TOOL_RESPONSE_CHARS`) to prevent LLM overflow: -- **Query results**: Truncate by char count, show complete rows that fit -- **Schema**: Truncate large schemas, use `search_schema()` for exploration -- **Single objects**: Return DuckDB schema summary instead of full data - -Agents use **ContextVar** for request isolation: `_graphql_queries`, `_query_results`, `_last_result`, `_raw_schema`. Use mutable containers (lists/dicts) since `ContextVar.set()` in child tasks doesn't propagate to parent. - -### Tool Naming - -Tools have internal names (`_query`, `_execute`) transformed by middleware per session: -- **Format**: `{prefix}_query`, `{prefix}_execute` — prefix from hostname or `X-API-Name` header -- Skips generic parts: TLDs, `api`, `qa`, `dev`, `internal` -- **Recipe tools**: `r_{slug}` (not API-specific), max 60 chars; `send_tool_list_changed()` notifies clients - -### Safety - -- **GraphQL**: Mutations blocked (queries only) -- **REST**: POST/PUT/DELETE/PATCH blocked by default, enable via `X-Allow-Unsafe-Paths` header (glob patterns) - -### Polling (REST only) - -Set `X-Poll-Paths` header to enable `poll_until_done` tool: -- Auto-increments `polling.count` in body between polls -- Checks `done_field` (dot-path like `"status"`, `"trips.0.isCompleted"`) against `done_value` -- Max 20 polls (configurable), default 3s delay - -### Recipes - -Caches parameterized API call + SQL pipelines from successful agent runs, exposed as MCP tools: - -``` -Query → Agent executes → Extractor LLM → Recipe stored → MCP tool `r_{name}` exposed -``` - -- **Storage**: LRU in-memory (default 64 entries), keyed by `(api_id, schema_hash)` - auto-invalidates on schema change -- **Deduplication**: Skips equivalent recipes, ensures unique tool names -- **Templating**: GraphQL `{{param}}`, REST `{"$param": "name"}`, SQL `{{param}}` -- **Config**: `ENABLE_RECIPES` (default: True), `RECIPE_CACHE_SIZE` (default: 64) - -## After Code Changes - -Always run before marking task complete: -```bash -uv run ruff check --fix api_agent/ # Lint + auto-fix -uv run ruff format api_agent/ # Format -uv run ty check # Type check -uv run pytest tests/ -v # Tests -``` - -## Testing Notes - -Tests use pytest-asyncio. Mock httpx for HTTP calls. See `tests/test_*.py` for patterns. - -CI runs tests + linting on Python 3.11/3.12 (see `.github/workflows/test.yml`). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5df4ab..212f16c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,9 +5,17 @@ Thank you for your interest in contributing! ## Development Setup 1. Clone the repository -2. Install dependencies: `uv sync --group dev` -3. Run tests: `uv run pytest tests/ -v` -4. Run linter: `uv run ruff check api_agent/` +2. Install dependencies: + ```bash + uv sync --group dev + ``` +3. Run locally: + ```bash + OPENAI_API_KEY=your_key uv run api-agent + ``` + +By default, local runs read `api-agent.toml` from the repository root. Set +`API_AGENT_CONFIG=/path/to/api-agent.toml` to use another config file. ## Code Style @@ -18,17 +26,33 @@ Thank you for your interest in contributing! ## Testing -- Write unit tests for new code -- Ensure all tests pass before submitting PR -- Aim for >80% coverage on new code +Run the full local check set before submitting a PR: + +```bash +uv lock --check +uv run ruff check api_agent/ tests/ +uv run ruff format --check api_agent/ tests/ +uv run ty check +uv run pytest tests/ -q +``` + +Write tests for new behavior. Prefer behavior-focused tests over tests that pin +private implementation details. + +## Changelog + +Update [CHANGELOG.md](CHANGELOG.md) for notable service changes. The changelog is +for service history tracking; it is not tied to package publishing, GitHub +Releases, or tag-based deployment. ## Pull Request Process 1. Fork the repo and create a feature branch 2. Make your changes with clear commit messages 3. Update documentation if needed -4. Ensure tests pass and linting is clean -5. Submit PR with description of changes +4. Update [CHANGELOG.md](CHANGELOG.md) for notable service changes +5. Ensure tests pass and linting is clean +6. Submit PR with a concise description of changes ## Reporting Issues @@ -39,4 +63,4 @@ Thank you for your interest in contributing! ## Questions? -Open a GitHub Discussion or Issue. +Open a GitHub Issue. diff --git a/Dockerfile b/Dockerfile index 95fc674..5eae16e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,12 +15,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app # Copy project files -COPY pyproject.toml uv.lock README.md ./ +COPY pyproject.toml uv.lock README.md api-agent.toml ./ + +# Install dependencies +RUN uv sync --frozen --no-dev --no-install-project + +# Install OpenTelemetry instrumentation libraries +RUN uv run --no-sync opentelemetry-bootstrap -a requirements > /tmp/otel-requirements.txt \ + && uv pip install -r /tmp/otel-requirements.txt \ + && rm /tmp/otel-requirements.txt + COPY api_agent ./api_agent -COPY start.sh ./ -# Install Python dependencies -RUN uv sync --frozen --no-dev +# Install project +RUN uv pip install --no-deps . + +COPY start.sh ./ EXPOSE 3000 diff --git a/README.md b/README.md index b1b97a2..03d59ba 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,53 @@ # API Agent -**Turn any API into an MCP server. Query in English. Get results—even when the API can't.** +**Turn any API into an MCP server. Query in English. Get results even when the API can't.** -Point at any GraphQL or REST API. Ask questions in natural language. The agent fetches data, stores it in DuckDB, and runs SQL post-processing. Rankings, filters, JOINs work **even if the API doesn't support them**. +Point at any GraphQL or REST API. Ask questions in natural language. The agent fetches data, stores it in DuckDB, and runs SQL post-processing. Rankings, filters, and joins over returned data work **even if the API doesn't support them**. ## What Makes It Different -**🎯 Zero config.** No custom MCP code per API. Point at a GraphQL endpoint or OpenAPI spec — schema introspected automatically. +**🎯 Zero config.** No custom MCP code per API. Point at a GraphQL endpoint, OpenAPI 3.x spec, or Swagger 2.0 spec. The agent introspects/loads schema automatically. -**✨ SQL post-processing.** API returns 10,000 unsorted rows? Agent ranks top 10. No GROUP BY? Agent aggregates. Need JOINs across endpoints? Agent combines. +**✨ SQL post-processing.** API returns 10,000 unsorted rows? Agent ranks top 10. No GROUP BY? Agent aggregates. Need to join returned tables? Agent combines. The API doesn't need to support it—the agent does. **🔒 Safe by default.** Read-only. Mutations blocked unless explicitly allowed. -**🧠 Recipe learning.** Successful queries become cached pipelines. Reuse instantly without LLM reasoning. +**🧠 Recipe learning.** Agent samples successful queries into reusable workflows. Ask once, reuse cached pipelines that execute without LLM reasoning. Learned recipes are exposed as tools named `r_`. ## Quick Start -**1. Run (choose one):** +**1. Run:** ```bash # Direct run (no clone needed) OPENAI_API_KEY=your_key uvx --from git+https://github.com/agoda-com/api-agent api-agent -# Or clone & run +# Or clone git clone https://github.com/agoda-com/api-agent.git && cd api-agent -uv sync && OPENAI_API_KEY=your_key uv run api-agent +uv sync --group dev +``` + +Set the OpenAI secret in env: + +```bash +export OPENAI_API_KEY=your_key +``` + +Direct `uvx` runs use built-in defaults unless `API_AGENT_CONFIG` points to a +TOML file. A cloned checkout uses `api-agent.toml` from the repository root by +default. + +**2. Start local clone:** + +```bash +uv run api-agent # Or Docker -git clone https://github.com/agoda-com/api-agent docker build -t api-agent . docker run -p 3000:3000 -e OPENAI_API_KEY=your_key api-agent ``` -**2. Add to any MCP client:** +**3. Add to any MCP client:** ```json { "mcpServers": { @@ -47,16 +62,16 @@ docker run -p 3000:3000 -e OPENAI_API_KEY=your_key api-agent } ``` -**3. Ask questions:** +**4. Ask questions:** - *"Show characters from Earth, only alive ones, group by species"* - *"Top 10 characters by episode count"* - *"Compare alive vs dead by species, only species with 10+ characters"* -That's it. Agent introspects schema, generates queries, runs SQL post-processing. +That's it. Agent introspects schema, generates calls, runs SQL post-processing. ## More Examples -**REST API (Petstore — OpenAPI 3.x):** +**REST API (Petstore):** ```json { "mcpServers": { @@ -71,21 +86,6 @@ That's it. Agent introspects schema, generates queries, runs SQL post-processing } ``` -**REST API (Petstore — Swagger 2.0):** -```json -{ - "mcpServers": { - "petstore": { - "url": "http://localhost:3000/mcp", - "headers": { - "X-Target-URL": "https://petstore.swagger.io/v2/swagger.json", - "X-API-Type": "rest" - } - } - } -} -``` - **Your own API with auth:** ```json { @@ -110,74 +110,104 @@ That's it. Agent introspects schema, generates queries, runs SQL post-processing | Header | Required | Description | | ---------------------- | -------- | ---------------------------------------------------------- | -| `X-Target-URL` | Yes | GraphQL endpoint OR OpenAPI/Swagger spec URL (3.x and 2.0) | +| `X-Target-URL` | Yes | GraphQL endpoint OR OpenAPI/Swagger spec URL | | `X-API-Type` | Yes | `graphql` or `rest` | | `X-Target-Headers` | No | JSON auth headers, e.g. `{"Authorization": "Bearer xxx"}` | +| `X-Passthrough-Headers`| No | JSON array of header names to copy from this MCP request onto outbound API calls (merged after `X-Target-Headers`) | | `X-API-Name` | No | Override tool name prefix (default: auto-generated) | | `X-Base-URL` | No | Override base URL for REST API calls | -| `X-Allow-Unsafe-Paths` | No | Header string containing JSON array of `fnmatch` globs (`*`, `?`) for POST/PUT/DELETE/PATCH | -| `X-Poll-Paths` | No | Header string containing JSON array of polling path patterns (enables poll tool) | -| `X-Include-Result` | No | Include full uncapped `result` field in output | +| `X-Allow-Unsafe-Paths` | No | JSON array of glob patterns for POST/PUT/DELETE/PATCH | +| `X-Poll-Paths` | No | JSON array of REST paths requiring polling | +| `X-Include-Result` | No | Truthy values include full `result` in wrapped output | +| `X-Recipe-Learn-Rate` | No | Override recipe sample rate for this request, `0` to `1` | +| `X-Debug` | No | Truthy values include `debug` metadata with calls and trace id when available | -#### Header value examples +`X-Passthrough-Headers` is a JSON array of header names, for example `["Authorization", "X-Request-Id"]`. For each name that appears on the MCP request (matching is case-insensitive), the value is copied into the headers sent to the target API. Use this when the client already sends auth or tracing headers on the MCP connection and you want those same values forwarded without duplicating them in `X-Target-Headers`. Entries missing from the request are skipped. -`X-Allow-Unsafe-Paths` and `X-Poll-Paths` use the same escaping format: JSON array encoded as a header string. +Truthy header values are `true`, `1`, and `yes` (case-insensitive). `X-Recipe-Learn-Rate: 1` always samples successful queries for recipe extraction; `0` disables learning for the request. Invalid optional JSON headers are ignored and default to empty values. -**MCP config (JSON):** -```json -{ - "headers": { - "X-Allow-Unsafe-Paths": "[\"/search\", \"/api/*/query\", \"/jobs/*/cancel\"]", - "X-Poll-Paths": "[\"/search\", \"/trips/*/status\"]" - } -} -``` +### MCP Tools -**`X-Allow-Unsafe-Paths` pattern examples:** -- `"/search"` exact path -- `"/api/*/query"` one wildcard segment -- `"/jobs/*"` any suffix under `/jobs/` +**Core tool** (prefix auto-generated from URL or `X-API-Name`): -**`X-Poll-Paths` pattern examples:** -- `"/search"` exact polling path -- `"/trips/*/status"` wildcard polling path +| Tool | Input | Output | +| ------------------ | -------------------------------------------------------------- | ------------------------------- | +| `{prefix}_query` | Natural language question + optional `return_directly` | `{ok, data, error, result?}` or CSV | -`X-Poll-Paths` enables polling guidance/tooling; `X-Allow-Unsafe-Paths` controls unsafe method allowlist. +`X-API-Name` preserves hyphens in the prefix; the tool suffix separator remains `_` +(for example, `weather-alerts` exposes `weather-alerts_query`). -**Escaping quick check (same for both headers):** -- wrong: `"X-Allow-Unsafe-Paths": "["/search"]"` -- right: `"X-Allow-Unsafe-Paths": "[\"/search\"]"` +If a tool call used `return_directly`, the MCP response is raw CSV instead of the wrapper. Otherwise `data` contains the agent summary and `result` contains retrieved rows when available. -### MCP Tools +Set `X-Debug: true` to force wrapped JSON and include a `debug` object: + +```json +{ + "ok": true, + "data": "...", + "debug": { + "api_calls": [{"method": "GET", "path": "/users/{id}", "success": true}], + "trace_id": "62ee6f56fc839a7291d09935a0974727" + }, + "error": null +} +``` -**Core tools** (2 per API): +For GraphQL debug responses, calls are returned under `debug.queries`. For REST debug responses, calls are returned under `debug.api_calls`. -| Tool | Input | Output | -| ------------------ | -------------------------------------------------------------- | ------------------------------- | -| `{prefix}_query` | Natural language question | `{ok, data, queries/api_calls}` | -| `{prefix}_execute` | GraphQL: `query`, `variables` / REST: `method`, `path`, params | `{ok, data}` | +`debug.trace_id` is included only when OTEL tracing is enabled and a trace is active. + +**Recipe tools** (dynamically added as the agent learns): -Tool names auto-generated from URL (e.g., `example_query`). Override with `X-API-Name`. +| Tool | Input | Output | +| ---------- | ------------------------------------------------------------- | --------------- | +| `r_{slug}` | Intent args | CSV | -**Recipe tools** (dynamic, added as recipes are learned): +- Slug derived from LLM-suggested recipe name, max 49 chars total including `r_`. +- All declared tool args are **required** top-level fields. +- Recipe tools always return directly as CSV. -| Tool | Input | Output | -| ------------------ | ---------------------------------- | ------ | -| `r_{recipe_slug}` | flat recipe-specific params, `return_directly` (bool) | CSV or `{ok, data, executed_queries/calls}` | +**Schema tools** (agent-internal): -Cached pipelines, no LLM reasoning. Appear after successful queries. Clients notified via `tools/list_changed`. +During query planning, agents also have `search_schema(pattern, context?, before?, after?, offset?)` for grep-like regex search over raw GraphQL introspection or OpenAPI JSON. This is used when compact schema context is truncated. ### Configuration -| Variable | Required | Default | Description | -| ----------------------------- | -------- | ------------------------- | ---------------------------------- | -| `OPENAI_API_KEY` | **Yes** | - | OpenAI API key (or custom LLM key) | -| `OPENAI_BASE_URL` | No | https://api.openai.com/v1 | Custom LLM endpoint | -| `API_AGENT_MODEL_NAME` | No | gpt-5.2 | Model (e.g., gpt-5.2) | -| `API_AGENT_PORT` | No | 3000 | Server port | -| `API_AGENT_ENABLE_RECIPES` | No | true | Enable recipe learning & caching | -| `API_AGENT_RECIPE_CACHE_SIZE` | No | 64 | Max cached recipes (LRU eviction) | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | No | - | OpenTelemetry tracing endpoint | +App configuration lives in `api-agent.toml`. Environment variables do not override app settings except listed overrides. `API_AGENT_CONFIG` selects a TOML file path; Docker defaults it to `/app/api-agent.toml`. A cloned checkout reads `api-agent.toml` by default. Direct installed runs, including `uvx`, use built-in defaults unless `API_AGENT_CONFIG` points to a TOML file. `OPENAI_API_KEY` is env-only; `OPENAI_BASE_URL` and `PORT` can be set in TOML and overridden by env. + +| TOML key | Default | Notes | +| --- | --- | --- | +| `[mcp].name` | `API Agent` | MCP display name | +| `[server].host` | `0.0.0.0` | bind host | +| `[server].port` | `3000` | bind port; env `PORT` may override | +| `[server].transport` | `streamable-http` | `http`, `streamable-http`, or `sse` | +| `[server].stateless_http` | `true` | stateless MCP HTTP mode | +| `[server].cors_allowed_origins` | `*` | comma-separated origins | +| `[server].debug` | `false` | debug logging | +| `[model].api` | `responses` | `responses` or `chat_completions` | +| `[model].name` | `gpt-5.5` | model name | +| `[model].openai_base_url` | `https://api.openai.com/v1` | env `OPENAI_BASE_URL` may override | +| `[model].reasoning_effort` | `low` | model reasoning effort | +| `[description].model_name` | `gpt-5.4-mini` | optional tool description model; empty uses `[model].name` | +| `[description].timeout_seconds` | `15` | tool description generation timeout | +| `[agent].max_turns` | `30` | max agent turns per query | +| `[agent].max_response_chars` | `50000` | max final response chars | +| `[agent].max_schema_chars` | `32000` | compact schema cap | +| `[agent].max_preview_rows` | `10` | result preview rows | +| `[agent].max_tool_response_chars` | `32000` | tool response context cap | +| `[polling].max_polls` | `20` | max REST poll attempts | +| `[polling].default_delay_ms` | `3000` | default REST poll delay | +| `[polling].max_delay_ms` | `3000` | max REST poll delay | +| `[recipes].enabled` | `true` | enable learned recipes | +| `[recipes].max_size` | `1000` | max stored recipes | +| `[recipes].learn_rate` | `0.2` | deterministic sample rate | +| `[storage].backend` | `memory` | `memory` or `redis` | +| `[storage].namespace` | `api-agent` | Redis key namespace | +| `[redis].url` | empty | required only for Redis storage | + +Docker copies this TOML to `/app/api-agent.toml`, and `start.sh` sets `API_AGENT_CONFIG=/app/api-agent.toml` by default. Keep app settings in TOML, set `OPENAI_API_KEY` in env, and leave proxy/OTEL to env. + +`[storage]` backs learned recipes and generated downstream tool descriptions. Generated descriptions are cached by API id and schema hash. If description generation fails or times out, API Agent returns a deterministic fallback and caches that fallback for 5 minutes before retrying generation. --- @@ -198,7 +228,7 @@ sequenceDiagram G-->>A: Data → stored in DuckDB A->>A: SQL post-processing A-->>M: Summary - M-->>U: {ok, data, queries[]} + M-->>U: {ok, data, result?} ``` ## Architecture @@ -211,8 +241,7 @@ flowchart TB subgraph MCP["MCP Server (FastMCP)"] Q["{prefix}_query"] - E["{prefix}_execute"] - R["r_{recipe} (dynamic)"] + R["r_{recipe_slug}"] end subgraph Agent["Agents (OpenAI Agents SDK)"] @@ -228,9 +257,7 @@ flowchart TB Client -->|NL + headers| MCP Q -->|graphql| GA Q -->|rest| RA - E --> HTTP - R -->|"no LLM"| HTTP - R --> Duck + R --> HTTP GA --> HTTP RA --> HTTP GA --> Duck @@ -240,40 +267,104 @@ flowchart TB **Stack:** [FastMCP](https://github.com/jlowin/fastmcp) • [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) • [DuckDB](https://duckdb.org) +Agents use the OpenAI Responses API model path by default. Set `[model].api = "chat_completions"` only for models/endpoints that support Chat Completions. + +Current target scope: each query runs against one configured API target. Multi-endpoint support should expose multiple named targets, but cross-target joins are intentionally out of scope for now. + +REST specs can be JSON or YAML. OpenAPI 3.x is used directly; Swagger 2.0 is normalized into an OpenAPI 3.0-compatible shape. For REST base URLs, the agent uses `X-Base-URL` first, then `servers[0].url`, then derives the origin from the spec URL. + +--- + +## REST Polling + +Set `X-Poll-Paths` to enable the `poll_until_done` agent tool for async REST endpoints: + +```json +["/jobs", "/search"] +``` + +The agent must use `poll_until_done` for matching paths. It polls until `done_field` equals `done_value`, supports dot paths like `status` or `trips.0.isCompleted`, waits `[polling].default_delay_ms` by default, caps waits at `[polling].max_delay_ms`, stops after `[polling].max_polls`, and increments `body.polling.count` when present. + --- ## Recipe Learning -Agent learns reusable patterns from successful queries: +Agent samples successful queries into reusable MCP tools. Recipe capture is best-effort after the user-facing query succeeds; recipe extraction/store failures are logged and do not fail the query response. -1. **Executes** — API calls + SQL via LLM reasoning -2. **Extracts** — LLM converts trace into parameterized template -3. **Caches** — Stores recipe keyed by (API, schema hash) -4. **Exposes** — Recipe becomes MCP tool (`r_{name}`) callable without LLM +1. **Captures** - Records successful API and SQL tool calls into one ordered `steps` list. +2. **Samples** - Uses `X-Recipe-Learn-Rate` when present, otherwise `[recipes].learn_rate`. +3. **Extracts** - Structured extractor proposes a public MCP tool contract plus private optimized execution plan. +4. **Validates** - Runs the candidate once with fixture tool args and compares final rows to the original result. + Extractor prompts receive bounded result samples; deterministic replay keeps the full local result. +5. **Caches** - Stores by API id + schema hash with fingerprint deduplication. +6. **Reuses** - Similar `{prefix}_query` requests get recipe tools injected for the agent to choose. +7. **Exposes** - Learned recipes are also published as direct MCP tools named `r_`. ```mermaid -flowchart LR - subgraph First["First Query via {prefix}_query"] - Q1["'Top 5 users by age'"] - A1["Agent reasons"] - E1["API + SQL"] - R1["Recipe extracted"] +flowchart TD + Q["{prefix}_query
natural language"] --> S["Load schema"] + S --> M["Search recipes
api id + schema hash + question"] + M --> A["Agent run
recipe tools + API/SQL tools"] + A --> C["Capture successful ordered steps
API and SQL interleaved"] + C --> R["Return query response"] + R --> L{"learn rate sample?"} + L -- no --> Done["Done"] + L -- yes --> X["Structured extractor
public contract + private plan"] + X --> V{"candidate result
matches original result?"} + V -- no --> Done + V -- yes --> Store["Store recipe
fingerprint dedupe"] + Store --> List["Next list_tools exposes r_{slug}"] + + subgraph Direct["Direct recipe call"] + T["r_{slug}"] --> P["Map public args to private plan
run without agent reasoning"] end +``` - subgraph Tools["MCP Tools"] - T["r_get_top_users
params: {limit}"] - end +**Recipe structure:** +- `public_contract` contains MCP `tool_name`, description, and public `tool_args`. +- `execution_plan.steps` is a dataflow graph. Each step has `id`, `kind`, `input`, and `output.name`. +- `input.mode="single"` uses public args via `input.with`; `map` and `batch` read one prior rowset via `input.from` + `input.bind`. +- Multiple dependencies must first be joined by SQL into one ordered binding rowset; there is no implicit Cartesian product. +- **GraphQL**: `call.query_template` with `{{param}}` placeholders. +- **REST**: `call.path_params`, `call.query_params`, `call.body` with `{"$var": "name"}` refs. List query params are encoded as repeated keys. +- **SQL**: `query_template` inside the same ordered `steps` list. - subgraph Reuse["Direct Call"] - Q2["r_get_top_users({limit: 10})"] - X["Execute directly"] - end +Mapped REST dependency shape: - Q1 --> A1 --> E1 --> R1 --> T - Q2 --> T --> X +```json +{ + "id": "key_results", + "kind": "rest", + "input": { + "mode": "map", + "from": "filtered_objectives", + "bind": {"objective_id": "id"}, + "with": {} + }, + "call": { + "method": "GET", + "path": "/objectives/{objective_id}/key-results", + "path_params": {"objective_id": {"$var": "objective_id"}} + }, + "output": { + "name": "key_results", + "attach_binding": ["objective_id"] + } +} ``` -Recipes auto-expire on schema changes. Disable with `API_AGENT_ENABLE_RECIPES=false`. +`input.from` must name one prior `output.name`. For two upstream datasets, add a SQL step that joins them into one rowset first, then `map` from that SQL output. + +**Recipe tools:** +- Each cached recipe is surfaced as an MCP tool named `r_{slug}` (slugified from LLM-suggested name, max 49 chars). +- Recipe names use concise `snake_case` action-resource slugs. Descriptions are outcome-focused and do not inject API labels or implementation step counts. +- If multiple recipes share the same slug, the most recently used one is exposed. +- Tool args are **flat top-level fields** (not nested under `params`), all required, and must be user-intent inputs. +- Clients call these tools directly — no LLM reasoning, just cached API+SQL pipeline. + +For normal `{prefix}_query` calls, recipe reuse is still agent-mediated: matching recipes are exposed as tools and prompt hints, then the agent decides whether to call one. If the agent uses a recipe tool, that run is not learned again, and the recipe tool returns directly by default. Direct `r_{slug}` calls bypass the agent entirely and always return directly as CSV. + +Recipes are hidden when schema changes (hash mismatch). Storage is memory by default or Redis when configured. Recipe eviction is FIFO by `recipes.max_size`; recipes have no TTL. --- @@ -283,11 +374,27 @@ Recipes auto-expire on schema changes. Disable with `API_AGENT_ENABLE_RECIPES=fa git clone https://github.com/agoda-com/api-agent.git cd api-agent uv sync --group dev -uv run pytest tests/ -v # Tests -uv run ruff check api_agent/ # Lint -uv run ty check # Type check +uv lock --check +uv run ruff check api_agent/ tests/ +uv run ruff format --check api_agent/ tests/ +uv run ty check +uv run pytest tests/ -q ``` +Agent guidance lives in `AGENTS.md`. See [CONTRIBUTING.md](CONTRIBUTING.md) for +contribution guidelines and [CHANGELOG.md](CHANGELOG.md) for service history. + ## Observability -Set `OTEL_EXPORTER_OTLP_ENDPOINT` to enable OpenTelemetry tracing. Works with Jaeger, Zipkin, Grafana Tempo, Arize Phoenix. +OpenTelemetry tracing is opt-in. For local tracing, run through `opentelemetry-instrument` +and set either: + +```bash +OTEL_SERVICE_NAME=api-agent OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ + uv run --no-sync opentelemetry-instrument api-agent +# or +OTEL_SERVICE_NAME=api-agent OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces \ + uv run --no-sync opentelemetry-instrument api-agent +``` + +`start.sh` uses `opentelemetry-instrument` automatically when an OTEL endpoint is configured, so Docker can use the same env vars without changing the entrypoint. API Agent exports OpenAI Agents SDK spans over OTLP HTTP. Works with Jaeger, Grafana Tempo, Arize Phoenix, and other OTLP collectors. When not configured, tracing setup is skipped; queries still run and `X-Debug` still returns calls, but `debug.trace_id` is omitted. diff --git a/api-agent.toml b/api-agent.toml new file mode 100644 index 0000000..c448dce --- /dev/null +++ b/api-agent.toml @@ -0,0 +1,44 @@ +[mcp] +name = "API Agent" + +[server] +host = "0.0.0.0" +port = 3000 +transport = "streamable-http" +stateless_http = true +cors_allowed_origins = "*" +debug = false + +[model] +api = "responses" +name = "gpt-5.5" +openai_base_url = "https://api.openai.com/v1" +reasoning_effort = "low" + +[description] +model_name = "gpt-5.4-mini" +timeout_seconds = 15 + +[agent] +max_turns = 30 +max_response_chars = 50000 +max_schema_chars = 32000 +max_preview_rows = 10 +max_tool_response_chars = 32000 + +[polling] +max_polls = 20 +default_delay_ms = 3000 +max_delay_ms = 3000 + +[recipes] +enabled = true +max_size = 1000 +learn_rate = 0.2 + +[storage] +backend = "memory" +namespace = "api-agent" + +[redis] +url = "" diff --git a/api_agent/__main__.py b/api_agent/__main__.py index 45e8eda..19c01aa 100644 --- a/api_agent/__main__.py +++ b/api_agent/__main__.py @@ -8,11 +8,10 @@ from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware from starlette.responses import JSONResponse -from starlette.routing import Route from .config import settings from .middleware import DynamicToolNamingMiddleware -from .tools import register_all_tools +from .tools import register_public_tools logging.basicConfig( level=logging.DEBUG if settings.DEBUG else logging.INFO, @@ -21,11 +20,21 @@ logger = logging.getLogger(__name__) -def create_app(): - """Create MCP server application.""" - mcp = FastMCP(settings.MCP_NAME) - register_all_tools(mcp) +def create_public_mcp() -> FastMCP: + """Create public MCP server.""" + mcp = FastMCP(settings.MCP_NAME, strict_input_validation=True) + register_public_tools(mcp) mcp.add_middleware(DynamicToolNamingMiddleware()) + return mcp + + +def create_app(): + """Create ASGI application.""" + public_mcp = create_public_mcp() + + @public_mcp.custom_route("/health", methods=["GET"]) + async def health(request): + return JSONResponse({"status": "ok"}) cors_origins = [o.strip() for o in settings.CORS_ALLOWED_ORIGINS.split(",")] middleware = [ @@ -46,13 +55,12 @@ def create_app(): ] transport = cast(Literal["http", "streamable-http", "sse"], settings.TRANSPORT) - app = mcp.http_app(middleware=middleware, transport=transport) - - async def health(request): - return JSONResponse({"status": "ok"}) - - app.router.routes.append(Route("/health", health, methods=["GET"])) - return app + return public_mcp.http_app( + path="/mcp", + middleware=middleware, + stateless_http=settings.STATELESS_HTTP, + transport=transport, + ) def main(): diff --git a/api_agent/agent/graphql_agent.py b/api_agent/agent/graphql_agent.py index 0f440a9..68fcfaf 100644 --- a/api_agent/agent/graphql_agent.py +++ b/api_agent/agent/graphql_agent.py @@ -3,11 +3,12 @@ import json import logging import re +from collections.abc import Callable from contextvars import ContextVar from datetime import datetime from typing import Any -from agents import Agent, MaxTurnsExceeded, Runner, function_tool +from agents import function_tool from ..config import settings from ..context import RequestContext @@ -17,28 +18,27 @@ truncate_for_context, ) from ..graphql import execute_query as graphql_fetch -from ..recipe import ( - RECIPE_STORE, - _return_directly_flag, - _set_return_directly, - _tools_to_final_output, - build_api_id, - build_partial_result, - build_recipe_docstring, - create_params_model, - deduplicate_tool_name, +from ..graphql.schema_context import build_schema_context +from ..recipe.contracts import ( + get_recipe_description, + get_recipe_tool_args, + get_recipe_tool_name, + resolve_recipe_values, +) +from ..recipe.execution import ( + collect_step_rows, + error_json, execute_recipe_steps, format_recipe_response, - maybe_extract_and_save_recipe, - render_text_template, - search_recipes, - validate_and_prepare_recipe, + render_graphql_query_sets, + store_step_rows, validate_recipe_params, ) -from ..tracing import trace_metadata +from ..recipe.learning import async_validate_and_prepare_recipe +from ..recipe.search import build_api_id +from ..recipe.state import _set_return_directly, mark_recipe_tool_used +from ..recipe.tooling import build_recipe_docstring, create_params_model, deduplicate_tool_name from .contextvar_utils import safe_append_contextvar_list, safe_get_contextvar -from .model import get_run_config, model -from .progress import get_turn_context, reset_progress from .prompts import ( CONTEXT_SECTION, DECISION_GUIDANCE, @@ -46,12 +46,14 @@ GRAPHQL_SCHEMA_NOTATION, OPTIONAL_PARAMS_SPEC, PERSISTENCE_SPEC, + REASONING_GUIDANCE, SEARCH_TOOL_DESC, SQL_RULES, SQL_TOOL_DESC, TOOL_USAGE_RULES, UNCERTAINTY_SPEC, ) +from .runtime import AgentRuntimeConfig, AgentRuntimeState, LoadedSchema, run_agent_query from .schema_search import create_search_schema_tool logger = logging.getLogger(__name__) @@ -71,22 +73,6 @@ def _log(msg: str) -> None: _query_results: ContextVar[dict[str, Any]] = ContextVar("query_results") _last_result: ContextVar[list] = ContextVar("last_result") # Mutable container: [result_value] _raw_schema: ContextVar[str] = ContextVar("raw_schema") # Raw introspection JSON for search -_sql_steps: ContextVar[list[str]] = ContextVar("sql_steps") - - -def _format_type(t: dict | None) -> str: - """Convert introspection type to compact notation: [User!]!""" - if not t: - return "?" - kind = t.get("kind") - name = t.get("name") - inner = t.get("ofType") - - if kind == "NON_NULL": - return f"{_format_type(inner)}!" - if kind == "LIST": - return f"[{_format_type(inner)}]" - return name or "?" _INTROSPECTION_QUERY = """{ @@ -122,97 +108,6 @@ def _format_type(t: dict | None) -> str: }""" -def _is_required(type_def: dict | None) -> bool: - """Check if GraphQL type is required (NON_NULL wrapper).""" - return type_def.get("kind") == "NON_NULL" if type_def else False - - -def _format_arg(a: dict) -> str: - """Format argument with optional default value.""" - type_str = _format_type(a["type"]) - default = a.get("defaultValue") - if default is not None: - return f"{a['name']}: {type_str} = {default}" - return f"{a['name']}: {type_str}" - - -def _filter_required_args(args: list[dict]) -> list[dict]: - """Filter to only required arguments (NON_NULL type).""" - return [a for a in args if _is_required(a.get("type"))] - - -def _format_field(fld: dict) -> str: - """Format a field with optional args.""" - args = fld.get("args", []) - if args: - arg_str = "(" + ", ".join(_format_arg(a) for a in args) + ")" - else: - arg_str = "" - desc = f" # {fld['description']}" if fld.get("description") else "" - return f" {fld['name']}{arg_str}: {_format_type(fld['type'])}{desc}" - - -def _build_schema_context(schema: dict) -> str: - """Build compact SDL context from introspection schema.""" - queries = schema.get("queryType", {}).get("fields", []) - all_types = [t for t in schema.get("types", []) if not t["name"].startswith("__")] - - objects = [ - t - for t in all_types - if t["kind"] == "OBJECT" and t["name"] not in ("Query", "Mutation", "Subscription") - ] - enums = [t for t in all_types if t["kind"] == "ENUM"] - inputs = [t for t in all_types if t["kind"] == "INPUT_OBJECT"] - interfaces = [t for t in all_types if t["kind"] == "INTERFACE"] - unions = [t for t in all_types if t["kind"] == "UNION"] - - lines = [""] - for f in queries: - desc = f" # {f['description']}" if f.get("description") else "" - # Only show required args - required_args = _filter_required_args(f.get("args", [])) - args = ", ".join(_format_arg(a) for a in required_args) - lines.append(f"{f['name']}({args}) -> {_format_type(f['type'])}{desc}") - - if interfaces: - lines.append("\n") - for t in interfaces: - impl = [p["name"] for p in t.get("possibleTypes", []) or []] - impl_str = f" # implemented by: {', '.join(impl)}" if impl else "" - fields = [_format_field(fld) for fld in t.get("fields", []) or []] - lines.append(f"{t['name']} {{{impl_str}\n" + "\n".join(fields) + "\n}") - - if unions: - lines.append("\n") - for t in unions: - types = [p["name"] for p in t.get("possibleTypes", []) or []] - lines.append(f"{t['name']}: {' | '.join(types)}") - - lines.append("\n") - for t in objects: - impl = [i["name"] for i in t.get("interfaces", []) or []] - impl_str = f" implements {', '.join(impl)}" if impl else "" - fields = [_format_field(fld) for fld in t.get("fields", []) or []] - lines.append(f"{t['name']}{impl_str} {{\n" + "\n".join(fields) + "\n}") - - lines.append("\n") - for e in enums: - vals = " | ".join(v["name"] for v in e.get("enumValues", [])) - lines.append(f"{e['name']}: {vals}") - - lines.append("\n") - for inp in inputs: - # Only show required input fields - required_fields = [ - f for f in (inp.get("inputFields", []) or []) if _is_required(f.get("type")) - ] - fields = ", ".join(f"{f['name']}: {_format_type(f['type'])}" for f in required_fields) - lines.append(f"{inp['name']} {{ {fields} }}") - - return "\n".join(lines) - - def _strip_descriptions(context: str) -> str: """Strip # comments from SDL context.""" return re.sub(r" #[^\n]*", "", context) @@ -246,7 +141,7 @@ async def _fetch_schema_context(endpoint: str, headers: dict[str, str] | None) - _raw_schema.set(json.dumps(schema, indent=2)) # Build DSL for LLM context - context = _build_schema_context(schema) + context = build_schema_context(schema) if len(context) > settings.MAX_SCHEMA_CHARS: context = _strip_descriptions(context) @@ -304,6 +199,8 @@ def _build_system_prompt(recipe_context: str = "") -> str: {CONTEXT_SECTION.format(current_date=current_date, max_turns=settings.MAX_AGENT_TURNS)} +{REASONING_GUIDANCE} + {recipe_context} {DECISION_GUIDANCE} @@ -365,7 +262,8 @@ async def graphql_query(query: str, name: str = "data", return_directly: bool = # Track successful step for recipe extraction safe_append_contextvar_list( - _recipe_steps, {"kind": "graphql", "query": query, "name": name} + _recipe_steps, + {"kind": "graphql", "query": query, "name": name, "result": stored_data}, ) safe_append_contextvar_list(_graphql_queries, query) @@ -391,11 +289,6 @@ async def graphql_query(query: str, name: str = "data", return_directly: bool = indent=2, ) - if not result.get("success"): - result["hint"] = ( - "Use search_schema to find valid field names, enum values, or required args" - ) - return json.dumps(result, indent=2) return graphql_query @@ -438,7 +331,7 @@ def sql_query(sql: str, return_directly: bool = False) -> str: pass # Track successful SQL for recipe extraction - safe_append_contextvar_list(_sql_steps, sql) + safe_append_contextvar_list(_recipe_steps, {"kind": "sql", "query": sql, "result": rows}) if return_directly: _set_return_directly() @@ -452,6 +345,44 @@ def sql_query(sql: str, return_directly: bool = False) -> str: return json.dumps(result, indent=2) +async def _execute_graphql_recipe_step( + ctx: RequestContext, + step: Any, + params: dict[str, Any], + results: dict[str, Any], + *, + record_query: Callable[[str], None] | None = None, + pretty_errors: bool = False, +) -> tuple[bool, Any, str, list[str] | None]: + if not isinstance(step, dict) or step.get("kind") != "graphql": + return False, None, error_json("invalid recipe step", pretty=pretty_errors), None + + rendered_queries, render_error = render_graphql_query_sets(step, params, results) + if render_error: + return False, None, error_json(render_error, pretty=pretty_errors), None + + combined_rows: list[Any] = [] + queries: list[str] = [] + for rendered in rendered_queries: + query = rendered.query + res = await graphql_fetch(query, None, ctx.target_url, ctx.target_headers) + if not res.get("success"): + return ( + False, + None, + error_json(res.get("error", "query failed"), pretty=pretty_errors), + None, + ) + + combined_rows.extend(collect_step_rows(res.get("data", {}), step, rendered.binding)) + queries.append(query) + if record_query: + record_query(query) + + store_step_rows(results, step, combined_rows) + return True, combined_rows, "", queries + + def _create_individual_recipe_tools( ctx: RequestContext, suggestions: list[dict[str, Any]], @@ -461,87 +392,62 @@ def _create_individual_recipe_tools( seen_names: set[str] = set() for s in suggestions: - recipe = RECIPE_STORE.get_recipe(s["recipe_id"]) - if not recipe: + recipe = s.get("recipe") + if not isinstance(recipe, dict): continue - tool_name = deduplicate_tool_name(s.get("tool_name", "unknown_recipe"), seen_names) - params_spec = recipe.get("params", {}) + tool_name = deduplicate_tool_name(get_recipe_tool_name(recipe), seen_names) + params_spec = get_recipe_tool_args(recipe) docstring = build_recipe_docstring( s["question"], - recipe.get("steps", []), - recipe.get("sql_steps", []), - "graphql", + [], + api_type="graphql", params_spec=params_spec, + description=get_recipe_description(recipe), ) def make_tool(rid: str, pspec: dict[str, Any], doc: str, tname: str): ParamsModel = create_params_model(pspec, tname) async def dynamic_recipe_tool( - params: ParamsModel, + params: ParamsModel, # ty: ignore[invalid-type-form] return_directly: bool = True, ) -> str: + mark_recipe_tool_used(rid) kwargs = params.model_dump() validated_params, error = validate_recipe_params(pspec, kwargs) if error: return error - recipe, validated_params, error = validate_and_prepare_recipe( + recipe, validated_params, error = await async_validate_and_prepare_recipe( rid, json.dumps(kwargs), _raw_schema ) if error: return error + assert recipe is not None + execution_params, error = resolve_recipe_values(recipe, validated_params or {}) + if error: + return error_json(error, pretty=False) async def graphql_step_executor(step_idx, step, params, results): - if not isinstance(step, dict) or step.get("kind") != "graphql": - return ( - False, - None, - json.dumps( - {"success": False, "error": "invalid recipe step"}, indent=2 - ), - None, - ) - - name = step.get("name") or "data" - tmpl = step.get("query_template") - if not isinstance(tmpl, str): - return ( - False, - None, - json.dumps( - {"success": False, "error": "missing query_template"}, indent=2 - ), - None, - ) - - query = render_text_template(tmpl, params) - res = await graphql_fetch(query, None, ctx.target_url, ctx.target_headers) - if not res.get("success"): - return ( - False, - None, - json.dumps( - {"success": False, "error": res.get("error", "query failed")}, - indent=2, - ), - None, - ) - - data = res.get("data", {}) - tables, _ = extract_tables_from_response(data, str(name)) - results.update(tables) + _ = step_idx + success, data, step_error, queries = await _execute_graphql_recipe_step( + ctx, + step, + params, + results, + record_query=lambda query: safe_append_contextvar_list( + _graphql_queries, query + ), + pretty_errors=True, + ) _query_results.set(results) - safe_append_contextvar_list(_graphql_queries, query) - return True, tables.get(str(name)), "", query + return success, data, step_error, queries executed_queries: list[str] = [] - if recipe is None or validated_params is None: - return json.dumps({"success": False, "error": "recipe validation failed"}) - success, last_data, executed_sql, error = await execute_recipe_steps( + success, _last_data, executed_sql, error = await execute_recipe_steps( recipe, - validated_params, + execution_params or {}, _query_results, _last_result, graphql_step_executor, @@ -550,10 +456,6 @@ async def graphql_step_executor(step_idx, step, params, results): if not success: return error - # Track executed SQL for tracing - for sql in executed_sql: - safe_append_contextvar_list(_sql_steps, sql) - if return_directly: _set_return_directly() @@ -573,142 +475,86 @@ async def graphql_step_executor(step_idx, step, params, results): return tools -async def process_query(question: str, ctx: RequestContext) -> dict[str, Any]: - """Process natural language query against GraphQL API. +async def _load_graphql_schema(ctx: RequestContext) -> LoadedSchema: + schema_ctx = await _fetch_schema_context(ctx.target_url, ctx.target_headers) + return LoadedSchema( + schema_context=schema_ctx, + raw_schema=safe_get_contextvar(_raw_schema, ""), + ) - Args: - question: Natural language question - ctx: Request context with target_url and target_headers - """ - try: - _log(f"QUERY {question[:80]}") - - # Reset per-request storage - # Use mutable containers so tool functions can modify in-place - # (ContextVar.set() in child tasks doesn't propagate to parent) - _graphql_queries.set([]) - _recipe_steps.set([]) - _sql_steps.set([]) - _query_results.set({}) - _last_result.set([None]) # Mutable list: [result_value] - _return_directly_flag.set([]) # Reset direct return flag - reset_progress() # Reset turn counter - - # Fetch schema with dynamic endpoint - schema_ctx = await _fetch_schema_context(ctx.target_url, ctx.target_headers) - - # Pre-flight recipe search - suggestions, recipe_context = [], "" - if settings.ENABLE_RECIPES: - raw_schema = safe_get_contextvar(_raw_schema, "") - api_id = build_api_id(ctx, "graphql") - suggestions, recipe_context = search_recipes(api_id, raw_schema, question) - if suggestions: - _log( - f"PRE-FLIGHT found={len(suggestions)} ids={[s['recipe_id'] for s in suggestions]}" - ) - elif raw_schema: - _log(f"PRE-FLIGHT no matches for api_id={api_id[:50]}") - - # Create tools with bound context - gql_tool = _create_graphql_query_tool(ctx) - tools = [gql_tool, sql_query, search_schema] - if suggestions: # Create individual recipe tools for each suggestion - recipe_tools = _create_individual_recipe_tools(ctx, suggestions) - tools = [*recipe_tools, *tools] - - # Create fresh agent with dynamic tools - agent = Agent( - name="graphql-agent", - model=model, - instructions=_build_system_prompt(recipe_context), - tools=tools, - tool_use_behavior=_tools_to_final_output, - ) - - # Inject schema into query - augmented_query = f"{schema_ctx}\n\nQuestion: {question}" if schema_ctx else question - # Run agent with MaxTurnsExceeded handling for partial results - queries = [] - last_data = None - turn_info = "" - try: - with trace_metadata({"mcp_name": settings.MCP_SLUG, "agent_type": "graphql"}): - result = await Runner.run( - agent, - augmented_query, - max_turns=settings.MAX_AGENT_TURNS, - run_config=get_run_config(), - ) - - queries = _graphql_queries.get() - last_data = _last_result.get()[0] - turn_info = get_turn_context(settings.MAX_AGENT_TURNS) +def _build_graphql_tools(ctx: RequestContext, state: AgentRuntimeState) -> list[Any]: + tools = [_create_graphql_query_tool(ctx), sql_query, search_schema] + if state.suggestions: + return [*_create_individual_recipe_tools(ctx, state.suggestions), *tools] + return tools - except MaxTurnsExceeded: - # Return partial results when turn limit exceeded - queries = _graphql_queries.get() - last_data = _last_result.get()[0] - turn_info = get_turn_context(settings.MAX_AGENT_TURNS) - return build_partial_result(last_data, queries, turn_info, "queries") - # Check if tool requested direct return (detected by marker) - is_direct_return = False - try: - is_direct_return = result.final_output == "__DIRECT_RETURN__" or bool( - _return_directly_flag.get() - ) - except LookupError: - pass - - # Early return for error cases (no extraction needed) - if not result.final_output and not is_direct_return: - if last_data: - return { - "ok": True, - "data": f"[Partial - {turn_info}] Data retrieved but agent didn't complete.", - "result": last_data, - "queries": queries, - "error": None, - } - return { - "ok": False, - "data": None, - "result": None, - "queries": queries, - "error": f"No output ({turn_info})", - } - - # Build result for success paths - if is_direct_return: - agent_output = None - else: - agent_output = str(result.final_output) - _log(f"DONE queries={len(queries)} output={agent_output[:100]}") - - await maybe_extract_and_save_recipe( - api_type="graphql", - api_id=build_api_id(ctx, "graphql"), - question=question, - steps=safe_get_contextvar(_recipe_steps, []), - sql_steps=safe_get_contextvar(_sql_steps, []), - raw_schema=safe_get_contextvar(_raw_schema, ""), +async def _validate_graphql_recipe_candidate( + ctx: RequestContext, + _state: AgentRuntimeState, + recipe: dict[str, Any], + tool_args: dict[str, Any], +) -> Any: + execution_params, error = resolve_recipe_values(recipe, tool_args) + if error: + return None + + query_results_var: ContextVar[dict[str, Any]] = ContextVar("graphql_recipe_validation_results") + last_result_var: ContextVar[list[Any]] = ContextVar("graphql_recipe_validation_last") + query_results_var.set({}) + last_result_var.set([None]) + + async def graphql_step_executor(step_idx, step, params, results): + _ = step_idx + success, data, step_error, queries = await _execute_graphql_recipe_step( + ctx, + step, + params, + results, ) + query_results_var.set(results) + return success, data, step_error, queries + + success, last_data, _executed_sql, _error = await execute_recipe_steps( + recipe, + execution_params or {}, + query_results_var, + last_result_var, + graphql_step_executor, + [], + ) + return last_data if success else None + + +def _build_graphql_prompt(_ctx: RequestContext, state: AgentRuntimeState) -> str: + return _build_system_prompt(state.recipe_context) + + +def _graphql_api_id(ctx: RequestContext, _state: AgentRuntimeState) -> str: + return build_api_id(ctx, "graphql") + + +_GRAPHQL_RUNTIME = AgentRuntimeConfig( + agent_name="graphql-agent", + agent_type="graphql", + call_key="queries", + calls_var=_graphql_queries, + recipe_steps_var=_recipe_steps, + query_results_var=_query_results, + last_result_var=_last_result, + raw_schema_var=_raw_schema, + load_schema=_load_graphql_schema, + build_tools=_build_graphql_tools, + build_prompt=_build_graphql_prompt, + build_api_id=_graphql_api_id, + log=_log, + done_log_label="queries", + exception_message="Agent error", + validate_recipe_candidate=_validate_graphql_recipe_candidate, +) - return { - "ok": True, - "data": agent_output, - "result": last_data, - "queries": queries, - "error": None, - } - - except Exception as e: - logger.exception("Agent error") - return { - "ok": False, - "data": None, - "queries": [], - "error": str(e), - } + +async def process_query(question: str, ctx: RequestContext) -> dict[str, Any]: + """Process natural language query against GraphQL API.""" + return await run_agent_query(question, ctx, _GRAPHQL_RUNTIME) diff --git a/api_agent/agent/model.py b/api_agent/agent/model.py index 197e777..18fbd1b 100644 --- a/api_agent/agent/model.py +++ b/api_agent/agent/model.py @@ -1,23 +1,22 @@ """Shared OpenAI model configuration for API agents.""" -from agents import ModelSettings, RunConfig, set_default_openai_api, set_tracing_disabled +from typing import Literal, cast + +from agents import ModelSettings, RunConfig, set_default_openai_api from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel -from agents.run import CallModelData, ModelInputData +from agents.models.openai_responses import OpenAIResponsesModel +from agents.run import CallModelData, ModelInputData, ToolExecutionConfig from openai import AsyncOpenAI from openai.types.shared import Reasoning from ..config import settings -from ..tracing import init_tracing -from ..tracing import is_enabled as tracing_enabled from .progress import get_turn_context, increment_turn -# Set default API mode -set_default_openai_api("chat_completions") +OPENAI_API_MODE = settings.MODEL_API +ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"] -# Initialize tracing -init_tracing() -if not tracing_enabled(): - set_tracing_disabled(True) +# Set default API mode +set_default_openai_api(OPENAI_API_MODE) # Shared OpenAI client client = AsyncOpenAI( @@ -25,11 +24,18 @@ base_url=settings.OPENAI_BASE_URL, ) + +def create_openai_model(api: str, model_name: str, openai_client: AsyncOpenAI): + """Create the Agents SDK model adapter for the configured OpenAI API.""" + if api == "responses": + return OpenAIResponsesModel(model=model_name, openai_client=openai_client) + if api == "chat_completions": + return OpenAIChatCompletionsModel(model=model_name, openai_client=openai_client) + raise ValueError(f"Unsupported model API: {api}") + + # Shared model instance -model = OpenAIChatCompletionsModel( - model=settings.MODEL_NAME, - openai_client=client, -) +model = create_openai_model(OPENAI_API_MODE, settings.MODEL_NAME, client) async def _inject_turn(call_data: CallModelData) -> ModelInputData: @@ -45,9 +51,11 @@ def get_run_config() -> RunConfig: """Get RunConfig with optional reasoning settings and turn injection.""" model_settings = None if settings.REASONING_EFFORT: - model_settings = ModelSettings(reasoning=Reasoning(effort=settings.REASONING_EFFORT)) # type: ignore[arg-type] + effort = cast(ReasoningEffort, settings.REASONING_EFFORT) + model_settings = ModelSettings(reasoning=Reasoning(effort=effort)) return RunConfig( model_settings=model_settings, call_model_input_filter=_inject_turn, + tool_execution=ToolExecutionConfig(max_function_tool_concurrency=1), ) diff --git a/api_agent/agent/prompts.py b/api_agent/agent/prompts.py index 47c9b79..70453bf 100644 --- a/api_agent/agent/prompts.py +++ b/api_agent/agent/prompts.py @@ -7,6 +7,14 @@ Use today's date to calculate relative dates (tomorrow, next week, etc.) """ +# Reasoning guidance for GPT-5.x low-effort runs +REASONING_GUIDANCE = """ +- Before tool calls, internally choose the smallest API/SQL plan that can answer the request. +- Do not expose chain-of-thought; final answers should give results, assumptions, and concise method notes only. +- For multi-step tasks, verify each step's output shape before the next call. +- If evidence is insufficient, say what is missing instead of guessing. +""" + # SQL rules (shared) SQL_RULES = """ - API responses TRUNCATED; full data in DuckDB table @@ -15,6 +23,7 @@ - Structs: t.field.subfield (dot notation) - Arrays: len(arr), arr[1] (1-indexed) - UUIDs: CAST(id AS VARCHAR) +- In COALESCE, CASE, UNION, and mixed-type expressions, cast IDs/dates/enums to VARCHAR - UNNEST: FROM t, UNNEST(t.arr) AS u(val) → t.col for original, u.val for element - EXCLUDE: SELECT * EXCLUDE (col) FROM t (not t.* EXCLUDE) - If joins/CTEs share column names, always qualify columns (e.g., table_alias.column) @@ -43,8 +52,8 @@ # Persistence on errors PERSISTENCE_SPEC = """ - If API call fails, analyze error and retry with corrected params -- Don't give up after first failure - adjust approach -- Use all {max_turns} turns if needed to complete task +- Retry only when the error suggests a concrete fix +- Stop early when the answer is ready or the remaining path is not useful """ # Effective patterns (reward good behaviors) @@ -62,10 +71,12 @@ When to use each approach: RECIPES (if listed in above): +- Check available recipe tools before direct API/SQL calls - Score >= 0.7: Strong match, prefer recipe if params available - Score < 0.7: Consider direct API/SQL instead - Use when question very similar to past query - SKIP if params unclear or question differs +- Recipe tools require all listed params; no implicit defaults at call-time DIRECT API CALLS (rest_call, graphql_query): - Simple data retrieval, no filtering needed diff --git a/api_agent/agent/rest_agent.py b/api_agent/agent/rest_agent.py index c7fe400..9b0cac6 100644 --- a/api_agent/agent/rest_agent.py +++ b/api_agent/agent/rest_agent.py @@ -1,13 +1,13 @@ """REST agent using declarative queries (REST API + DuckDB SQL).""" -import asyncio import json import logging +from collections.abc import Callable from contextvars import ContextVar from datetime import datetime from typing import Any -from agents import Agent, MaxTurnsExceeded, Runner, function_tool +from agents import function_tool from ..config import settings from ..context import RequestContext @@ -16,36 +16,38 @@ extract_tables_from_response, truncate_for_context, ) -from ..recipe import ( - RECIPE_STORE, - _return_directly_flag, - _set_return_directly, - _tools_to_final_output, - build_api_id, - build_partial_result, - build_recipe_docstring, - create_params_model, - deduplicate_tool_name, +from ..recipe.contracts import ( + get_recipe_description, + get_recipe_tool_args, + get_recipe_tool_name, + resolve_recipe_values, +) +from ..recipe.execution import ( + build_rest_call_record, + collect_step_rows, + error_json, execute_recipe_steps, format_recipe_response, - maybe_extract_and_save_recipe, - render_param_refs, - search_recipes, - validate_and_prepare_recipe, + get_rest_step_call, + render_rest_call_sets, + store_step_rows, validate_recipe_params, ) +from ..recipe.learning import async_validate_and_prepare_recipe +from ..recipe.search import build_api_id +from ..recipe.state import _set_return_directly, mark_recipe_tool_used +from ..recipe.tooling import build_recipe_docstring, create_params_model, deduplicate_tool_name from ..rest.client import execute_request +from ..rest.polling import create_poll_tool from ..rest.schema_loader import fetch_schema_context -from ..tracing import trace_metadata from .contextvar_utils import safe_append_contextvar_list, safe_get_contextvar -from .model import get_run_config, model -from .progress import get_turn_context, reset_progress from .prompts import ( CONTEXT_SECTION, DECISION_GUIDANCE, EFFECTIVE_PATTERNS, OPTIONAL_PARAMS_SPEC, PERSISTENCE_SPEC, + REASONING_GUIDANCE, REST_SCHEMA_NOTATION, REST_TOOL_DESC, SEARCH_TOOL_DESC, @@ -54,6 +56,7 @@ TOOL_USAGE_RULES, UNCERTAINTY_SPEC, ) +from .runtime import AgentRuntimeConfig, AgentRuntimeState, LoadedSchema, run_agent_query from .schema_search import create_search_schema_tool logger = logging.getLogger(__name__) @@ -73,58 +76,6 @@ def _log(msg: str) -> None: _query_results: ContextVar[dict[str, Any]] = ContextVar("query_results") _last_result: ContextVar[list] = ContextVar("last_result") # Mutable container: [result_value] _raw_schema: ContextVar[str] = ContextVar("raw_schema") # Raw OpenAPI JSON for search -_sql_steps: ContextVar[list[str]] = ContextVar("sql_steps") - - -def _get_nested_value(data: dict | None, path: str) -> Any: - """Extract value from nested dict/list using dot notation. - - Args: - data: Dictionary to extract from - path: Dot-separated path (e.g., "polling.completed", "trips.0.isCompleted") - - Returns: - Value at path or None if not found - """ - if not data or not path: - return None - keys = path.split(".") - current: Any = data - for key in keys: - if not isinstance(current, (dict, list)): - return None - if isinstance(current, list) and key.isdigit(): - idx = int(key) - if 0 <= idx < len(current): - current = current[idx] - else: - return None - elif isinstance(current, dict): - current = current.get(key) - else: - return None - if current is None: - return None - return current - - -def _set_nested_value(data: dict, path: str, value: Any) -> None: - """Set value in nested dict using dot notation, creating intermediate dicts. - - Args: - data: Dictionary to modify - path: Dot-separated path (e.g., "polling.count") - value: Value to set - """ - if not path: - return - keys = path.split(".") - current = data - for key in keys[:-1]: - if key not in current or not isinstance(current[key], dict): - current[key] = {} - current = current[key] - current[keys[-1]] = value def _build_system_prompt(poll_paths: tuple[str, ...] = (), recipe_context: str = "") -> str: @@ -145,7 +96,7 @@ def _build_system_prompt(poll_paths: tuple[str, ...] = (), recipe_context: str = Poll async API until done_field equals done_value. - done_field: dot-path (e.g., "status", "data.0.complete", "trips.0.isCompleted") - done_value: target value as string ("true", "COMPLETED") - - delay_ms: ms between polls (default: {settings.DEFAULT_POLL_DELAY_MS}ms) + - delay_ms: ms between polls (default: {settings.DEFAULT_POLL_DELAY_MS}ms, max: {settings.MAX_POLL_DELAY_MS}ms) - Auto-increments polling.count if present in body Max {settings.MAX_POLLS} polls. Polling paths: {paths_str} """ @@ -185,6 +136,8 @@ def _build_system_prompt(poll_paths: tuple[str, ...] = (), recipe_context: str = {CONTEXT_SECTION.format(current_date=current_date, max_turns=settings.MAX_AGENT_TURNS)} +{REASONING_GUIDANCE} + {recipe_context} {DECISION_GUIDANCE} @@ -294,6 +247,7 @@ async def rest_call( "path_params": pp, "query_params": qp, "body": bd, + "result": stored_data, }, ) except LookupError: @@ -332,147 +286,6 @@ async def rest_call( return rest_call -def _create_poll_tool(ctx: RequestContext, base_url: str): - """Create poll_until_done tool with bound context.""" - - @function_tool - async def poll_until_done( - method: str, - path: str, - done_field: str, - done_value: str, - body: str = "", - path_params: str = "", - query_params: str = "", - name: str = "poll_result", - delay_ms: int = 0, - ) -> str: - """Poll endpoint until done_field equals done_value. Auto-increments polling.count if present. - - Args: - method: HTTP method (POST typically) - path: API path - done_field: Dot-path to check (e.g., "status", "polling.completed", "trips.0.isCompleted") - done_value: Value indicating done (e.g., "true", "0", "COMPLETED", "100") - body: JSON string request body - path_params: JSON string for path values - query_params: JSON string for query params - name: Table name for sql_query (default: poll_result) - delay_ms: Delay between polls in ms (default: 3000ms) - - Returns: - JSON string with final response or error - """ - pp = json.loads(path_params) if path_params else None - qp = json.loads(query_params) if query_params else None - try: - body_dict = json.loads(body) if body else {} - except json.JSONDecodeError as e: - return json.dumps( - { - "success": False, - "error": f"Invalid body JSON: {e.msg}", - } - ) - - # Internal defaults from config - max_polls = settings.MAX_POLLS - wait_ms = delay_ms if delay_ms > 0 else settings.DEFAULT_POLL_DELAY_MS - current = None # Track last done_field value for error messages - - attempt = 0 - while attempt < max_polls: - attempt += 1 - - result = await execute_request( - method, - path, - pp, - qp, - body=body_dict if body_dict else None, - base_url=base_url, - headers=ctx.target_headers, - allow_unsafe_paths=list(ctx.allow_unsafe_paths), - ) - - # Track call - safe_append_contextvar_list( - _rest_calls, - { - "method": method, - "path": path, - "path_params": path_params, - "query_params": query_params, - "body": json.dumps(body_dict) if body_dict else "", - "name": name, - "poll_attempt": attempt, - "success": bool(result.get("success")), - }, - ) - - if not result.get("success"): - return json.dumps( - { - "success": False, - "error": result.get("error"), - "attempt": attempt, - } - ) - - data = result.get("data", {}) - - # Validate done_field exists on first response - current = _get_nested_value(data, done_field) - if current is None and attempt == 1: - keys = list(data.keys()) if isinstance(data, dict) else [] - return json.dumps( - { - "success": False, - "error": f"done_field '{done_field}' not found in response. Available keys: {keys}", - } - ) - - # Check if done_field value matches done_value (string comparison) - is_done = str(current).lower() == done_value.lower() - - if is_done: - # Store result for sql_query - try: - results = _query_results.get() - tables, _ = extract_tables_from_response(data, name) - results.update(tables) - stored = tables.get(name) - if stored is not None: - _last_result.get()[0] = stored - except LookupError: - pass - - return json.dumps( - { - "success": True, - **truncate_for_context(data if isinstance(data, list) else [data], name), - "attempts": attempt, - }, - indent=2, - ) - - await asyncio.sleep(wait_ms / 1000) - - # Auto-increment polling.count if present in body - if body_dict.get("polling", {}).get("count") is not None: - body_dict["polling"]["count"] += 1 - - return json.dumps( - { - "success": False, - "error": f"max_polls ({max_polls}) exceeded. Last {done_field} value: {current} (expected: {done_value})", - "attempts": attempt, - } - ) - - return poll_until_done - - @function_tool def sql_query(sql: str, return_directly: bool = False) -> str: """Run DuckDB SQL on stored REST API results. @@ -507,7 +320,7 @@ def sql_query(sql: str, return_directly: bool = False) -> str: pass # Track successful SQL for recipe extraction - safe_append_contextvar_list(_sql_steps, sql) + safe_append_contextvar_list(_recipe_steps, {"kind": "sql", "query": sql, "result": rows}) if return_directly: _set_return_directly() @@ -521,6 +334,56 @@ def sql_query(sql: str, return_directly: bool = False) -> str: return json.dumps(result, indent=2) +async def _execute_rest_recipe_step( + ctx: RequestContext, + base_url: str, + allow_unsafe_paths: list[str], + step: Any, + params: dict[str, Any], + results: dict[str, Any], + *, + record_call: Callable[[dict[str, Any]], None] | None = None, + pretty_errors: bool = False, +) -> tuple[bool, Any, str, list[dict[str, Any]] | None]: + if not isinstance(step, dict) or step.get("kind") != "rest": + return False, None, error_json("invalid recipe step", pretty=pretty_errors), None + + method, path, name = get_rest_step_call(step) + rendered_sets, render_error = render_rest_call_sets(step, params, results) + if render_error: + return False, None, error_json(render_error, pretty=pretty_errors), None + + combined_rows: list[Any] = [] + call_recs: list[dict[str, Any]] = [] + for rendered in rendered_sets: + res = await execute_request( + method, + path, + rendered.path_params, + rendered.query_params, + rendered.body, + base_url=base_url, + headers=ctx.target_headers, + allow_unsafe_paths=allow_unsafe_paths, + ) + if not res.get("success"): + return ( + False, + None, + error_json(res.get("error", "request failed"), pretty=pretty_errors), + None, + ) + + combined_rows.extend(collect_step_rows(res.get("data", {}), step, rendered.binding)) + call_rec = build_rest_call_record(method=method, path=path, name=name, rendered=rendered) + call_recs.append(call_rec) + if record_call: + record_call(call_rec) + + store_step_rows(results, step, combined_rows) + return True, combined_rows, "", call_recs + + def _create_individual_recipe_tools( ctx: RequestContext, base_url: str, @@ -531,100 +394,63 @@ def _create_individual_recipe_tools( seen_names: set[str] = set() for s in suggestions: - recipe = RECIPE_STORE.get_recipe(s["recipe_id"]) - if not recipe: + recipe = s.get("recipe") + if not isinstance(recipe, dict): continue - tool_name = deduplicate_tool_name(s.get("tool_name", "unknown_recipe"), seen_names) - params_spec = recipe.get("params", {}) + tool_name = deduplicate_tool_name(get_recipe_tool_name(recipe), seen_names) + params_spec = get_recipe_tool_args(recipe) docstring = build_recipe_docstring( s["question"], - recipe.get("steps", []), - recipe.get("sql_steps", []), + [], params_spec=params_spec, + description=get_recipe_description(recipe), ) def make_tool(rid: str, pspec: dict[str, Any], doc: str, tname: str): ParamsModel = create_params_model(pspec, tname) async def dynamic_recipe_tool( - params: ParamsModel, + params: ParamsModel, # ty: ignore[invalid-type-form] return_directly: bool = True, ) -> str: + mark_recipe_tool_used(rid) kwargs = params.model_dump() validated_params, error = validate_recipe_params(pspec, kwargs) if error: return error - recipe, validated_params, error = validate_and_prepare_recipe( + recipe, validated_params, error = await async_validate_and_prepare_recipe( rid, json.dumps(kwargs), _raw_schema ) if error: return error + assert recipe is not None + execution_params, error = resolve_recipe_values(recipe, validated_params or {}) + if error: + return error_json(error, pretty=False) async def rest_step_executor(step_idx, step, params, results): - if not isinstance(step, dict) or step.get("kind") != "rest": - return ( - False, - None, - json.dumps( - {"success": False, "error": "invalid recipe step"}, indent=2 - ), - None, - ) - - method = str(step.get("method", "GET")).upper() - path = str(step.get("path", "")) - name = str(step.get("name") or "data") - - pp = render_param_refs(step.get("path_params") or {}, params) - qp = render_param_refs(step.get("query_params") or {}, params) - bd = render_param_refs(step.get("body") or {}, params) - - res = await execute_request( - method, - path, - pp if isinstance(pp, dict) else None, - qp if isinstance(qp, dict) else None, - bd if isinstance(bd, dict) and bd else None, - base_url=base_url, - headers=ctx.target_headers, - allow_unsafe_paths=list(ctx.allow_unsafe_paths), + _ = step_idx + success, data, step_error, call_recs = await _execute_rest_recipe_step( + ctx, + base_url, + list(ctx.allow_unsafe_paths), + step, + params, + results, + record_call=lambda call_rec: safe_append_contextvar_list( + _rest_calls, call_rec + ), + pretty_errors=True, ) - if not res.get("success"): - return ( - False, - None, - json.dumps( - {"success": False, "error": res.get("error", "request failed")}, - indent=2, - ), - None, - ) - - data = res.get("data", {}) - tables, _ = extract_tables_from_response(data, name) - results.update(tables) _query_results.set(results) - - call_rec = { - "method": method, - "path": path, - "path_params": json.dumps(pp) if pp else "", - "query_params": json.dumps(qp) if qp else "", - "body": json.dumps(bd) if bd else "", - "name": name, - "success": True, - } - safe_append_contextvar_list(_rest_calls, call_rec) - return True, tables.get(name), "", call_rec + return success, data, step_error, call_recs executed_calls: list[dict[str, Any]] = [] - if recipe is None or validated_params is None: - return json.dumps({"success": False, "error": "recipe validation failed"}) - success, last_data, executed_sql, error = await execute_recipe_steps( + success, _last_data, executed_sql, error = await execute_recipe_steps( recipe, - validated_params, + execution_params or {}, _query_results, _last_result, rest_step_executor, @@ -633,10 +459,6 @@ async def rest_step_executor(step_idx, step, params, results): if not success: return error - # Track executed SQL for tracing - for sql in executed_sql: - safe_append_contextvar_list(_sql_steps, sql) - if return_directly: _set_return_directly() @@ -660,166 +482,115 @@ async def rest_step_executor(step_idx, step, params, results): search_schema = create_search_schema_tool(_raw_schema) -async def process_rest_query(question: str, ctx: RequestContext) -> dict[str, Any]: - """Process natural language query against REST API. - - Args: - question: Natural language question - ctx: Request context with target_url (OpenAPI spec) and target_headers - """ - try: - _log(f"QUERY {question[:80]}") - - # Reset per-request storage - _rest_calls.set([]) - _recipe_steps.set([]) - _sql_steps.set([]) - _query_results.set({}) - _last_result.set([None]) # Mutable list: [result_value] - _return_directly_flag.set([]) # Reset direct return flag - reset_progress() # Reset turn counter - - # Fetch schema context (target_url = OpenAPI spec URL) - schema_ctx, spec_base_url, raw_spec_json = await fetch_schema_context( - ctx.target_url, ctx.target_headers - ) - - # Store raw OpenAPI spec for search_schema tool - _raw_schema.set(raw_spec_json) - - # Use header override or spec-derived base URL - base_url = ctx.base_url or spec_base_url - if not base_url: - return { +async def _load_rest_schema(ctx: RequestContext) -> LoadedSchema: + schema_ctx, spec_base_url, raw_spec_json = await fetch_schema_context( + ctx.target_url, ctx.target_headers + ) + base_url = ctx.base_url or spec_base_url + if not base_url: + return LoadedSchema( + schema_context=schema_ctx, + raw_schema=raw_spec_json, + early_response={ "ok": False, "data": None, "api_calls": [], "error": "Could not determine base URL. Set X-Base-URL header or ensure spec has 'servers' field.", - } - - # Pre-flight recipe search - suggestions, recipe_context = [], "" - if settings.ENABLE_RECIPES: - raw_schema = safe_get_contextvar(_raw_schema, "") - api_id = build_api_id(ctx, "rest", base_url) - suggestions, recipe_context = search_recipes(api_id, raw_schema, question) - if suggestions: - _log( - f"PRE-FLIGHT found={len(suggestions)} ids={[s['recipe_id'] for s in suggestions]}" - ) - elif raw_schema: - _log(f"PRE-FLIGHT no matches for api_id={api_id[:50]}") - - # Create tools with bound context - rest_tool = _create_rest_call_tool(ctx, base_url) - - # Only include poll tool if user specified poll_paths header - include_polling = bool(ctx.poll_paths) - tools = [rest_tool, sql_query, search_schema] - if include_polling: - poll_tool = _create_poll_tool(ctx, base_url) - tools.insert(1, poll_tool) - if suggestions: # Create individual recipe tools for each suggestion - recipe_tools = _create_individual_recipe_tools(ctx, base_url, suggestions) - tools = [*recipe_tools, *tools] - - # Create fresh agent with dynamic tools - agent = Agent( - name="rest-agent", - model=model, - instructions=_build_system_prompt( - poll_paths=ctx.poll_paths, recipe_context=recipe_context + }, + ) + return LoadedSchema(schema_context=schema_ctx, raw_schema=raw_spec_json, base_url=base_url) + + +def _build_rest_tools(ctx: RequestContext, state: AgentRuntimeState) -> list[Any]: + tools = [_create_rest_call_tool(ctx, state.base_url), sql_query, search_schema] + if ctx.poll_paths: + tools.insert( + 1, + create_poll_tool( + ctx, + state.base_url, + rest_calls_var=_rest_calls, + query_results_var=_query_results, + last_result_var=_last_result, ), - tools=tools, - tool_use_behavior=_tools_to_final_output, ) + if state.suggestions: + return [*_create_individual_recipe_tools(ctx, state.base_url, state.suggestions), *tools] + return tools - # Inject schema into query - augmented_query = f"{schema_ctx}\n\nQuestion: {question}" if schema_ctx else question - - # Run agent with MaxTurnsExceeded handling for partial results - api_calls = [] - last_data = None - turn_info = "" - try: - with trace_metadata({"mcp_name": settings.MCP_SLUG, "agent_type": "rest"}): - result = await Runner.run( - agent, - augmented_query, - max_turns=settings.MAX_AGENT_TURNS, - run_config=get_run_config(), - ) - - api_calls = _rest_calls.get() - last_data = _last_result.get()[0] - turn_info = get_turn_context(settings.MAX_AGENT_TURNS) - - except MaxTurnsExceeded: - # Return partial results when turn limit exceeded - api_calls = _rest_calls.get() - last_data = _last_result.get()[0] - turn_info = get_turn_context(settings.MAX_AGENT_TURNS) - return build_partial_result(last_data, api_calls, turn_info, "api_calls") - # Check if tool requested direct return (detected by marker) - is_direct_return = False - try: - is_direct_return = result.final_output == "__DIRECT_RETURN__" or bool( - _return_directly_flag.get() - ) - except LookupError: - pass +async def _validate_rest_recipe_candidate( + ctx: RequestContext, + state: AgentRuntimeState, + recipe: dict[str, Any], + tool_args: dict[str, Any], +) -> Any: + execution_params, error = resolve_recipe_values(recipe, tool_args) + if error: + return None - # Early return for error cases (no extraction needed) - if not result.final_output and not is_direct_return: - if last_data: - return { - "ok": True, - "data": f"[Partial - {turn_info}] Data retrieved but agent didn't complete.", - "result": last_data, - "api_calls": api_calls, - "error": None, - } - return { - "ok": False, - "data": None, - "result": None, - "api_calls": api_calls, - "error": f"No output ({turn_info})", - } - - # Build result for success paths - if is_direct_return: - agent_output = None - else: - agent_output = str(result.final_output) - _log(f"DONE calls={len(api_calls)} output={agent_output[:100]}") - - # Skip polling recipes (v1) - skip_polling = any("poll_attempt" in c for c in safe_get_contextvar(_rest_calls, [])) - await maybe_extract_and_save_recipe( - api_type="rest", - api_id=build_api_id(ctx, "rest", base_url), - question=question, - steps=safe_get_contextvar(_recipe_steps, []), - sql_steps=safe_get_contextvar(_sql_steps, []), - raw_schema=safe_get_contextvar(_raw_schema, ""), - skip_condition=skip_polling, + query_results_var: ContextVar[dict[str, Any]] = ContextVar("rest_recipe_validation_results") + last_result_var: ContextVar[list[Any]] = ContextVar("rest_recipe_validation_last") + query_results_var.set({}) + last_result_var.set([None]) + + async def rest_step_executor(step_idx, step, params, results): + _ = step_idx + success, data, step_error, call_recs = await _execute_rest_recipe_step( + ctx, + state.base_url, + [], + step, + params, + results, ) + query_results_var.set(results) + return success, data, step_error, call_recs + + success, last_data, _executed_sql, _error = await execute_recipe_steps( + recipe, + execution_params or {}, + query_results_var, + last_result_var, + rest_step_executor, + [], + ) + return last_data if success else None + + +def _build_rest_prompt(ctx: RequestContext, state: AgentRuntimeState) -> str: + return _build_system_prompt(poll_paths=ctx.poll_paths, recipe_context=state.recipe_context) + + +def _rest_api_id(ctx: RequestContext, state: AgentRuntimeState) -> str: + return build_api_id(ctx, "rest", state.base_url) + + +def _skip_polling_recipe(_ctx: RequestContext, _state: AgentRuntimeState) -> bool: + return any("poll_attempt" in c for c in safe_get_contextvar(_rest_calls, [])) + + +_REST_RUNTIME = AgentRuntimeConfig( + agent_name="rest-agent", + agent_type="rest", + call_key="api_calls", + calls_var=_rest_calls, + recipe_steps_var=_recipe_steps, + query_results_var=_query_results, + last_result_var=_last_result, + raw_schema_var=_raw_schema, + load_schema=_load_rest_schema, + build_tools=_build_rest_tools, + build_prompt=_build_rest_prompt, + build_api_id=_rest_api_id, + log=_log, + done_log_label="calls", + exception_message="REST Agent error", + skip_recipe=_skip_polling_recipe, + validate_recipe_candidate=_validate_rest_recipe_candidate, +) + - return { - "ok": True, - "data": agent_output, - "result": last_data, - "api_calls": api_calls, - "error": None, - } - - except Exception as e: - logger.exception("REST Agent error") - return { - "ok": False, - "data": None, - "api_calls": [], - "error": str(e), - } +async def process_rest_query(question: str, ctx: RequestContext) -> dict[str, Any]: + """Process natural language query against REST API.""" + return await run_agent_query(question, ctx, _REST_RUNTIME) diff --git a/api_agent/agent/runtime.py b/api_agent/agent/runtime.py new file mode 100644 index 0000000..954d0cc --- /dev/null +++ b/api_agent/agent/runtime.py @@ -0,0 +1,314 @@ +"""Shared agent runtime orchestration.""" + +from __future__ import annotations + +import logging +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable + +from agents import Agent, MaxTurnsExceeded, ModelRefusalError, Runner + +from ..config import settings +from ..context import RequestContext +from ..recipe.execution import build_partial_result +from ..recipe.learning import maybe_extract_and_save_recipe +from ..recipe.search import search_recipes +from ..recipe.state import ( + _return_directly_flag, + _tools_to_final_output, + recipe_tool_was_used, + reset_recipe_tool_usage, +) +from ..tracing import agent_span_attributes, span_trace_id, trace_metadata, trace_span +from .contextvar_utils import safe_get_contextvar +from .model import get_run_config, model +from .progress import get_turn_context, reset_progress + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class LoadedSchema: + """Protocol-specific schema load result.""" + + schema_context: str + raw_schema: str = "" + base_url: str = "" + early_response: dict[str, Any] | None = None + + +@dataclass +class AgentRuntimeState: + """Per-run state shared with protocol adapters.""" + + schema_context: str + raw_schema: str + base_url: str = "" + suggestions: list[dict[str, Any]] = field(default_factory=list) + recipe_context: str = "" + + +@dataclass(frozen=True) +class AgentRunResult: + """Normalized result from one Agents SDK run.""" + + output: Any | None + calls: list[Any] + last_data: Any + turn_info: str + trace_id: str | None + error: str | None = None + + +@dataclass(frozen=True) +class AgentRuntimeConfig: + """Protocol adapter hooks for the shared runner.""" + + agent_name: str + agent_type: str + call_key: str + calls_var: ContextVar[list[Any]] + recipe_steps_var: ContextVar[list[dict[str, Any]]] + query_results_var: ContextVar[dict[str, Any]] + last_result_var: ContextVar[list[Any]] + raw_schema_var: ContextVar[str] + load_schema: Callable[[RequestContext], Awaitable[LoadedSchema]] + build_tools: Callable[[RequestContext, AgentRuntimeState], list[Any]] + build_prompt: Callable[[RequestContext, AgentRuntimeState], str] + build_api_id: Callable[[RequestContext, AgentRuntimeState], str] + log: Callable[[str], None] + done_log_label: str + exception_message: str + skip_recipe: Callable[[RequestContext, AgentRuntimeState], bool] = lambda _ctx, _state: False + validate_recipe_candidate: ( + Callable[ + [RequestContext, AgentRuntimeState, dict[str, Any], dict[str, Any]], Awaitable[Any] + ] + | None + ) = None + + +async def run_agent_query( + question: str, + ctx: RequestContext, + config: AgentRuntimeConfig, +) -> dict[str, Any]: + """Run one protocol-specific agent through the shared lifecycle.""" + try: + config.log(f"QUERY {question[:80]}") + _reset_runtime(config) + + loaded = await config.load_schema(ctx) + config.raw_schema_var.set(loaded.raw_schema) + if loaded.early_response is not None: + return loaded.early_response + + state = AgentRuntimeState( + schema_context=loaded.schema_context, + raw_schema=loaded.raw_schema, + base_url=loaded.base_url, + ) + await _load_recipe_context(question, ctx, config, state) + + agent = Agent( + name=config.agent_name, + model=model, + instructions=config.build_prompt(ctx, state), + tools=config.build_tools(ctx, state), + tool_use_behavior=_tools_to_final_output, + ) + + run = await _run_agent(agent, question, config, state) + if run.error: + return _with_trace_id( + { + "ok": False, + "data": None, + config.call_key: run.calls, + "error": run.error, + }, + run.trace_id, + ) + if run.output is None: + return _with_trace_id( + build_partial_result(run.last_data, run.calls, run.turn_info, config.call_key), + run.trace_id, + ) + + is_direct_return = _is_direct_return(run.output.final_output) + if not run.output.final_output and not is_direct_return: + return _with_trace_id( + _empty_output_result(run.last_data, run.calls, run.turn_info, config.call_key), + run.trace_id, + ) + + agent_output = None if is_direct_return else str(run.output.final_output) + if agent_output is not None: + config.log(f"DONE {config.done_log_label}={len(run.calls)} output={agent_output[:100]}") + + async def validate_candidate(recipe: dict[str, Any], tool_args: dict[str, Any]) -> Any: + if config.validate_recipe_candidate is None: + return None + return await config.validate_recipe_candidate(ctx, state, recipe, tool_args) + + await maybe_extract_and_save_recipe( + api_type=config.agent_type, + api_id=config.build_api_id(ctx, state), + question=question, + steps=safe_get_contextvar(config.recipe_steps_var, []), + raw_schema=safe_get_contextvar(config.raw_schema_var, ""), + skip_condition=config.skip_recipe(ctx, state) or recipe_tool_was_used(), + learn_rate=ctx.learning_rate, + strong_recipe_match=any(s.get("score", 0) >= 0.8 for s in state.suggestions), + original_result=run.last_data, + validate_candidate=validate_candidate, + ) + + return _with_trace_id( + { + "ok": True, + "data": agent_output, + "result": run.last_data, + config.call_key: run.calls, + "error": None, + }, + run.trace_id, + ) + + except Exception as e: + logger.exception(config.exception_message) + return { + "ok": False, + "data": None, + config.call_key: [], + "error": str(e), + } + + +def _reset_runtime(config: AgentRuntimeConfig) -> None: + config.calls_var.set([]) + config.recipe_steps_var.set([]) + config.query_results_var.set({}) + config.last_result_var.set([None]) + config.raw_schema_var.set("") + _return_directly_flag.set([]) + reset_recipe_tool_usage() + reset_progress() + + +async def _load_recipe_context( + question: str, + ctx: RequestContext, + config: AgentRuntimeConfig, + state: AgentRuntimeState, +) -> None: + if not settings.ENABLE_RECIPES: + return + + api_id = config.build_api_id(ctx, state) + try: + suggestions, recipe_context = await search_recipes(api_id, state.raw_schema, question) + except Exception: + logger.exception( + "Recipe lookup failed; continuing without recipes api_id=%s agent_type=%s", + api_id[:100], + config.agent_type, + ) + suggestions, recipe_context = [], "" + state.suggestions = suggestions + state.recipe_context = recipe_context + + if suggestions: + config.log( + f"PRE-FLIGHT found={len(suggestions)} ids={[s['recipe_id'] for s in suggestions]}" + ) + elif state.raw_schema: + config.log(f"PRE-FLIGHT no matches for api_id={api_id[:50]}") + + +async def _run_agent( + agent: Agent, + question: str, + config: AgentRuntimeConfig, + state: AgentRuntimeState, +) -> AgentRunResult: + augmented_query = ( + f"{state.schema_context}\n\nQuestion: {question}" if state.schema_context else question + ) + + trace_id = None + with trace_span( + f"{config.agent_type}.query", + agent_span_attributes(settings.MCP_SLUG, config.agent_type), + ) as span: + trace_id = span_trace_id(span) + with trace_metadata({"mcp_name": settings.MCP_SLUG, "agent_type": config.agent_type}): + try: + result = await Runner.run( + agent, + augmented_query, + max_turns=settings.MAX_AGENT_TURNS, + run_config=get_run_config(), + ) + except ModelRefusalError as e: + return AgentRunResult( + output=None, + calls=[], + last_data=config.last_result_var.get()[0], + turn_info=get_turn_context(settings.MAX_AGENT_TURNS), + trace_id=trace_id, + error=f"Model refused: {e.refusal}", + ) + except MaxTurnsExceeded: + return AgentRunResult( + output=None, + calls=config.calls_var.get(), + last_data=config.last_result_var.get()[0], + turn_info=get_turn_context(settings.MAX_AGENT_TURNS), + trace_id=trace_id, + ) + + return AgentRunResult( + output=result, + calls=config.calls_var.get(), + last_data=config.last_result_var.get()[0], + turn_info=get_turn_context(settings.MAX_AGENT_TURNS), + trace_id=trace_id, + ) + + +def _is_direct_return(final_output: Any) -> bool: + try: + return final_output == "__DIRECT_RETURN__" or bool(_return_directly_flag.get()) + except LookupError: + return False + + +def _empty_output_result( + last_data: Any, + calls: list[Any], + turn_info: str, + call_key: str, +) -> dict[str, Any]: + if last_data: + return { + "ok": True, + "data": f"[Partial - {turn_info}] Data retrieved but agent didn't complete.", + "result": last_data, + call_key: calls, + "error": None, + } + return { + "ok": False, + "data": None, + "result": None, + call_key: calls, + "error": f"No output ({turn_info})", + } + + +def _with_trace_id(payload: dict[str, Any], trace_id: str | None) -> dict[str, Any]: + if trace_id: + payload["trace_id"] = trace_id + return payload diff --git a/api_agent/config.py b/api_agent/config.py index b64b88f..1e3b3c7 100644 --- a/api_agent/config.py +++ b/api_agent/config.py @@ -1,19 +1,124 @@ """Configuration settings for API Agent MCP server.""" +import os import re +import tomllib +from pathlib import Path +from typing import Any, Literal -from pydantic import AliasChoices, Field, computed_field -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import computed_field +from pydantic.fields import FieldInfo +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict + +_TOML_MAP: dict[str, dict[str, str]] = { + "mcp": { + "name": "MCP_NAME", + }, + "server": { + "host": "HOST", + "port": "PORT", + "transport": "TRANSPORT", + "stateless_http": "STATELESS_HTTP", + "cors_allowed_origins": "CORS_ALLOWED_ORIGINS", + "debug": "DEBUG", + }, + "model": { + "api": "MODEL_API", + "name": "MODEL_NAME", + "openai_base_url": "OPENAI_BASE_URL", + "reasoning_effort": "REASONING_EFFORT", + }, + "agent": { + "max_turns": "MAX_AGENT_TURNS", + "max_response_chars": "MAX_RESPONSE_CHARS", + "max_schema_chars": "MAX_SCHEMA_CHARS", + "max_preview_rows": "MAX_PREVIEW_ROWS", + "max_tool_response_chars": "MAX_TOOL_RESPONSE_CHARS", + }, + "polling": { + "max_polls": "MAX_POLLS", + "default_delay_ms": "DEFAULT_POLL_DELAY_MS", + "max_delay_ms": "MAX_POLL_DELAY_MS", + }, + "recipes": { + "enabled": "ENABLE_RECIPES", + "store": "STORAGE_BACKEND", + "max_size": "RECIPE_CACHE_SIZE", + "learn_rate": "RECIPE_LEARN_RATE", + "namespace": "STORAGE_NAMESPACE", + }, + "storage": { + "backend": "STORAGE_BACKEND", + "namespace": "STORAGE_NAMESPACE", + }, + "redis": { + "url": "REDIS_URL", + }, + "description": { + "model_name": "DESCRIPTION_MODEL_NAME", + "timeout_seconds": "DESCRIPTION_TIMEOUT_SECONDS", + }, +} + +_ENV_OVERRIDES = ("OPENAI_API_KEY", "OPENAI_BASE_URL", "PORT") + + +def _load_toml_settings() -> dict[str, Any]: + path = Path(os.environ.get("API_AGENT_CONFIG", "api-agent.toml")) + values: dict[str, Any] = {} + + if path.exists(): + with path.open("rb") as f: + raw = tomllib.load(f) + + for section, mapping in _TOML_MAP.items(): + section_values = raw.get(section, {}) + if not isinstance(section_values, dict): + continue + for toml_name, field_name in mapping.items(): + if toml_name in section_values: + values[field_name] = section_values[toml_name] + + for field_name in _ENV_OVERRIDES: + if field_name in os.environ: + values[field_name] = os.environ[field_name] + return values + + +class ApiAgentTomlSettingsSource(PydanticBaseSettingsSource): + """Read api-agent.toml into existing flat settings fields.""" + + def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: + return None, field_name, False + + def __call__(self) -> dict[str, Any]: + return _load_toml_settings() class Settings(BaseSettings): - """Settings with API_AGENT_ env prefix. OPENAI_* also accepts unprefixed.""" + """Settings loaded from api-agent.toml. + + API_AGENT_CONFIG may select the TOML path. OpenAI secrets may come from env. + """ - model_config = SettingsConfigDict(env_prefix="API_AGENT_", env_file=".env", extra="ignore") + model_config = SettingsConfigDict(extra="ignore") + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + return ( + init_settings, + ApiAgentTomlSettingsSource(settings_cls), + ) # MCP Server MCP_NAME: str = "API Agent" - SERVICE_NAME: str = "api-agent" @computed_field @property @@ -21,17 +126,14 @@ def MCP_SLUG(self) -> str: """Slugified MCP_NAME for identifiers.""" return re.sub(r"[^a-z0-9]+", "_", self.MCP_NAME.lower()).strip("_") - # LLM (accepts both API_AGENT_OPENAI_* and OPENAI_*) - OPENAI_API_KEY: str = Field( - default="", - validation_alias=AliasChoices("API_AGENT_OPENAI_API_KEY", "OPENAI_API_KEY"), - ) - OPENAI_BASE_URL: str = Field( - default="https://api.openai.com/v1", - validation_alias=AliasChoices("API_AGENT_OPENAI_BASE_URL", "OPENAI_BASE_URL"), - ) - MODEL_NAME: str = "gpt-5.2" - REASONING_EFFORT: str = "" # "low", "medium", "high" - empty = disabled + # LLM + OPENAI_API_KEY: str = "" + OPENAI_BASE_URL: str = "https://api.openai.com/v1" + MODEL_NAME: str = "gpt-5.5" + MODEL_API: Literal["responses", "chat_completions"] = "responses" + REASONING_EFFORT: str = "low" + DESCRIPTION_MODEL_NAME: str = "gpt-5.4-mini" + DESCRIPTION_TIMEOUT_SECONDS: float = 15.0 # Agent limits MAX_AGENT_TURNS: int = 30 @@ -43,17 +145,27 @@ def MCP_SLUG(self) -> str: # Polling limits MAX_POLLS: int = 20 # Max poll attempts DEFAULT_POLL_DELAY_MS: int = 3000 # Default delay if agent doesn't specify + MAX_POLL_DELAY_MS: int = 3000 # Cap delay between poll attempts # Server DEBUG: bool = False HOST: str = "0.0.0.0" PORT: int = 3000 TRANSPORT: str = "streamable-http" + STATELESS_HTTP: bool = True CORS_ALLOWED_ORIGINS: str = "*" - # Recipes (in-process reuse) + # Recipes ENABLE_RECIPES: bool = True - RECIPE_CACHE_SIZE: int = 64 + RECIPE_CACHE_SIZE: int = 1000 + RECIPE_LEARN_RATE: float = 0.2 + + # Storage + STORAGE_BACKEND: str = "memory" + STORAGE_NAMESPACE: str = "api-agent" + + # Redis-backed storage + REDIS_URL: str = "" settings = Settings() diff --git a/api_agent/context.py b/api_agent/context.py index 611732c..86542ec 100644 --- a/api_agent/context.py +++ b/api_agent/context.py @@ -1,12 +1,20 @@ """Request context extraction from HTTP headers.""" import json +import logging import re from dataclasses import dataclass +from typing import Literal, cast from urllib.parse import urlparse from fastmcp.server.dependencies import get_http_headers +logger = logging.getLogger(__name__) + + +ApiType = Literal["graphql", "rest"] +_TRUE_VALUES = {"true", "1", "yes"} + class MissingHeaderError(Exception): """Required header missing from request.""" @@ -19,16 +27,23 @@ class RequestContext: """Per-request context extracted from headers.""" target_url: str # X-Target-URL: GraphQL endpoint or OpenAPI spec URL - api_type: str # X-API-Type: "graphql" or "rest" + api_type: ApiType # X-API-Type: "graphql" or "rest" target_headers: dict # X-Target-Headers: parsed JSON headers allow_unsafe_paths: tuple[str, ...] # X-Allow-Unsafe-Paths: glob patterns for POST/etc base_url: str | None # X-Base-URL: override base URL (REST only) include_result: bool # X-Include-Result: whether to include full result in output poll_paths: tuple[str, ...] # X-Poll-Paths: paths that require polling (enables poll tool) + learning_rate: float | None = None # X-Recipe-Learn-Rate: per-request sample rate + debug: bool = False # X-Debug: include trace/call debugging metadata def get_request_context() -> RequestContext: - """Extract context from current request headers. + """Extract context from current HTTP request headers.""" + return parse_request_context(get_http_headers(include={"authorization"})) + + +def parse_request_context(headers: dict[str, str]) -> RequestContext: + """Parse request context from normalized HTTP headers. Required headers: X-Target-URL: Target API endpoint (GraphQL) or OpenAPI spec URL (REST) @@ -36,26 +51,28 @@ def get_request_context() -> RequestContext: Optional headers: X-Target-Headers: JSON object with auth headers to forward + X-Passthrough-Headers: JSON array of header names to copy from this request + into target headers (merged after X-Target-Headers) X-Allow-Unsafe-Paths: JSON array of glob patterns for POST/PUT/DELETE/PATCH X-Base-URL: Override base URL for REST API calls X-Include-Result: Include full uncapped result in output (default: false) X-Poll-Paths: JSON array of paths requiring polling (enables poll tool) + X-Recipe-Learn-Rate: Override recipe sample rate for this request (0..1) + X-Debug: Include trace/call debugging metadata Raises: MissingHeaderError: If required headers are missing or invalid """ - headers = get_http_headers() - target_url = headers.get("x-target-url") api_type = headers.get("x-api-type") target_headers_raw = headers.get("x-target-headers") or "{}" + passthrough_headers_raw = headers.get("x-passthrough-headers") or "[]" allow_unsafe_paths_raw = headers.get("x-allow-unsafe-paths") or "[]" base_url_raw = headers.get("x-base-url") include_result_raw = headers.get("x-include-result", "false") poll_paths_raw = headers.get("x-poll-paths") or "[]" - - base_url = base_url_raw if base_url_raw else None - include_result = (include_result_raw or "").lower() in ("true", "1", "yes") + learning_rate_raw = headers.get("x-recipe-learn-rate") + debug_raw = headers.get("x-debug", "false") if not target_url: raise MissingHeaderError("X-Target-URL header required") @@ -66,30 +83,67 @@ def get_request_context() -> RequestContext: if api_type not in ("graphql", "rest"): raise MissingHeaderError(f"X-API-Type must be 'graphql' or 'rest', got '{api_type}'") + typed_api_type = cast(ApiType, api_type) + extra = {"target_url": target_url, "api_type": api_type} + target_headers = _parse_json_object(target_headers_raw, "target headers", extra) + passthrough_names = _parse_json_string_tuple( + passthrough_headers_raw, + "passthrough headers", + extra, + ) + _merge_passthrough_headers(target_headers, headers, passthrough_names) + + return RequestContext( + target_url=target_url, + api_type=typed_api_type, + target_headers=target_headers, + allow_unsafe_paths=_parse_json_string_tuple( + allow_unsafe_paths_raw, + "allow unsafe paths", + extra, + ), + base_url=base_url_raw if base_url_raw else None, + include_result=_is_truthy(include_result_raw), + poll_paths=_parse_json_string_tuple(poll_paths_raw, "poll paths", extra), + learning_rate=_parse_learning_rate(learning_rate_raw), + debug=_is_truthy(debug_raw), + ) + + +def _is_truthy(value: str | None) -> bool: + return (value or "").lower() in _TRUE_VALUES + + +def _parse_learning_rate(raw: str | None) -> float | None: + if raw is None or raw == "": + return None try: - target_headers = json.loads(target_headers_raw) - except json.JSONDecodeError: - target_headers = {} + rate = float(raw) + except ValueError as exc: + raise MissingHeaderError("X-Recipe-Learn-Rate must be a number from 0 to 1") from exc + if rate < 0 or rate > 1: + raise MissingHeaderError("X-Recipe-Learn-Rate must be between 0 and 1") + return rate + +def _parse_json_object(raw: str, label: str, extra: dict[str, str]) -> dict: try: - allow_unsafe_paths = tuple(json.loads(allow_unsafe_paths_raw)) + parsed = json.loads(raw) except json.JSONDecodeError: - allow_unsafe_paths = () + logger.warning("Invalid JSON for %s", label, extra=extra) + return {} + return dict(parsed) if isinstance(parsed, dict) else {} + +def _parse_json_string_tuple(raw: str, label: str, extra: dict[str, str]) -> tuple[str, ...]: try: - poll_paths = tuple(json.loads(poll_paths_raw)) + parsed = json.loads(raw) except json.JSONDecodeError: - poll_paths = () - - return RequestContext( - target_url=target_url, - api_type=api_type, - target_headers=target_headers, - allow_unsafe_paths=allow_unsafe_paths, - base_url=base_url, - include_result=include_result, - poll_paths=poll_paths, - ) + logger.warning("Invalid JSON for %s", label, extra=extra) + return () + if not isinstance(parsed, list): + return () + return tuple(value for value in parsed if isinstance(value, str)) def _to_snake_case(name: str) -> str: @@ -99,6 +153,16 @@ def _to_snake_case(name: str) -> str: return name.lower().strip("_") +def _normalize_explicit_api_name(name: str) -> str: + """Normalize explicit X-API-Name while preserving hyphens.""" + cleaned = (name or "").strip().lower() + cleaned = re.sub(r"\s+", "_", cleaned) + cleaned = re.sub(r"[^a-z0-9_-]", "", cleaned) + cleaned = re.sub(r"_+", "_", cleaned) + cleaned = re.sub(r"-+", "-", cleaned) + return cleaned.strip("_-") or "api" + + def get_full_hostname(url: str | None) -> str: """Get full hostname from URL for description.""" if not url: @@ -111,7 +175,7 @@ def get_tool_name_prefix(url: str | None) -> str: """Get semantic prefix for tool name (≤32 chars). Extracts meaningful parts from hostname, skipping generic TLDs and infra names. - Example: flights-api-qa.internal.example.com → flights_api_example + Example: flights-service.internal.example.com → flights_service_example """ if not url: return "api" @@ -130,7 +194,6 @@ def get_tool_name_prefix(url: str | None) -> str: "is", "net", "org", - "privatecloud", "qa", "dev", "internal", @@ -149,8 +212,29 @@ def extract_api_name(headers: dict | None = None) -> str: # Explicit header takes priority if api_name := headers.get("x-api-name"): - return _to_snake_case(api_name)[:32] + return _normalize_explicit_api_name(api_name)[:32] # Fall back to semantic prefix from URL target_url = headers.get("x-target-url", "") return get_tool_name_prefix(target_url) + + +def _merge_passthrough_headers( + target_headers: dict[str, str], + client_headers: dict[str, str], + passthrough_names: tuple[str, ...], +) -> None: + """Copy listed headers from the MCP request into target_headers (mutates target_headers).""" + for raw_name in passthrough_names: + if not isinstance(raw_name, str): + continue + key_lower = raw_name.lower().strip() + if not key_lower or key_lower not in client_headers: + continue + canonical = _canonical_http_header_name(key_lower) + target_headers[canonical] = client_headers[key_lower] + + +def _canonical_http_header_name(name: str) -> str: + """Turn a lowercased header name into conventional Title-Case (e.g. x-request-id → X-Request-Id).""" + return "-".join(part.capitalize() for part in name.strip().split("-")) diff --git a/api_agent/description.py b/api_agent/description.py new file mode 100644 index 0000000..1bf2e98 --- /dev/null +++ b/api_agent/description.py @@ -0,0 +1,463 @@ +"""Downstream API description generation for MCP tools.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from dataclasses import dataclass +from typing import Any + +from agents import Agent, AgentOutputSchema, Runner +from agents.exceptions import ModelBehaviorError +from pydantic import BaseModel, ConfigDict, Field + +from .config import settings +from .store import ASYNC_API_AGENT_STORE + +logger = logging.getLogger(__name__) + +_MAX_DESCRIPTION_CONTEXT_CHARS = 12000 +_MAX_FALLBACK_DESCRIPTION_CHARS = 300 +_MAX_FIELD_COUNT = 40 +_MAX_PATH_COUNT = 40 +_FALLBACK_CACHE_TTL_SECONDS = 300 +_GENERIC_GRAPHQL_TYPE_PARTS = ( + "audit", + "connection", + "deprecated", + "edge", + "input", + "pageinfo", + "result", + "setting", +) +_GRAPHQL_DOMAIN_TYPE_PRIORITY = ( + "component", + "service", + "team", + "module", + "library", + "job", + "dataproject", + "platform", + "repository", +) + + +class DownstreamDescriptionOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + description: str = Field(min_length=40, max_length=500) + + +@dataclass(frozen=True) +class DescriptionResult: + text: str + ttl_seconds: int | None = None + + +DESCRIPTION_INSTRUCTIONS = """You are a downstream API description compiler. Convert a schema summary into one MCP tool description for the general query tool. + +INPUT: +- api_type: "graphql" or "rest" +- hostname: downstream API hostname +- schema_summary: compact GraphQL introspection or OpenAPI summary + +OUTPUT: Structured description. +{ + "description": "" +} + +DESCRIPTION REQUIREMENTS: +- 1-2 short sentences, 40-300 characters. +- Write for an agent choosing whether to call this general natural-language query tool. +- Start with what the agent can ask or look up through this tool. +- Say what the downstream service actually does, not how API Agent can query it. +- Prefer the service/product/domain name from title, description, tags, operation names, type names, or field names. +- Mention concrete resources and actions from the schema, such as service accounts, tokens, team accounts, settings, emails, objectives, bookings, payments, tickets, reports, or metrics. +- Keep it broad enough for the general query tool, but grounded in this service's real capabilities. +- Make clear this is for questions about that downstream service's data or workflows. +- For REST APIs with write operations, mention actions only when the schema summary clearly exposes them. +- Use active client-facing language: "Manage...", "Look up...", "Search...", "Inspect...". + +DO NOT: +- Do not mention API Agent, gateway, proxy, wrapper, schema, OpenAPI, GraphQL, MCP, tool, or generated description. +- Do not say "this API", "the API", or "the service" without concrete domain context. +- Do not mention implementation details, endpoints, paths, HTTP methods, field counts, operation counts, or schema shape. +- Do not invent domains not supported by schema_summary. +- Do not include setup instructions, auth guidance, examples, markdown, or bullet points. +- Do not mention generic query mechanics like filtering, ranking, aggregation, joins, SQL, or live API calls. +""" + + +async def get_downstream_description( + *, + api_type: str, + hostname: str, + raw_schema: str, + api_id: str, + schema_hash: str, +) -> str: + """Return cached/generated downstream API description with deterministic fallback.""" + cached = await _get_cached_description(api_id=api_id, schema_hash=schema_hash) + if cached: + return cached + + fallback = fallback_downstream_description( + api_type=api_type, + hostname=hostname, + raw_schema=raw_schema, + ) + try: + result = await asyncio.wait_for( + _generate_downstream_description( + api_type=api_type, + hostname=hostname, + raw_schema=raw_schema, + ), + timeout=settings.DESCRIPTION_TIMEOUT_SECONDS, + ) + except TimeoutError: + logger.info("Timed out generating downstream API description") + await _save_fallback_description(api_id=api_id, schema_hash=schema_hash, fallback=fallback) + return fallback + except Exception: + logger.exception("Failed to generate downstream API description") + await _save_fallback_description(api_id=api_id, schema_hash=schema_hash, fallback=fallback) + return fallback + + await _save_cached_description( + api_id=api_id, + schema_hash=schema_hash, + description=result.text, + ttl_seconds=result.ttl_seconds, + ) + return result.text + + +async def _save_fallback_description(*, api_id: str, schema_hash: str, fallback: str) -> None: + await _save_cached_description( + api_id=api_id, + schema_hash=schema_hash, + description=fallback, + ttl_seconds=_FALLBACK_CACHE_TTL_SECONDS, + ) + + +async def _get_cached_description(*, api_id: str, schema_hash: str) -> str | None: + try: + return await ASYNC_API_AGENT_STORE.get_downstream_description( + api_id=api_id, + schema_hash=schema_hash, + ) + except Exception: + logger.exception("Failed to read downstream API description cache") + return None + + +async def _save_cached_description( + *, api_id: str, schema_hash: str, description: str, ttl_seconds: int | None = None +) -> None: + try: + # Failure fallbacks are cached briefly to avoid repeated model calls, then retried. + await ASYNC_API_AGENT_STORE.save_downstream_description( + api_id=api_id, + schema_hash=schema_hash, + description=description, + ttl_seconds=ttl_seconds, + ) + except Exception: + logger.exception("Failed to write downstream API description cache") + + +def fallback_downstream_description(*, api_type: str, hostname: str, raw_schema: str = "") -> str: + if api_type != "graphql": + schema_description = _openapi_info_description(raw_schema) + if schema_description: + return schema_description + else: + schema_description = _graphql_schema_description(raw_schema, hostname) + if schema_description: + return schema_description + + api_label = "GraphQL" if api_type == "graphql" else "REST" + return ( + f"[{hostname} {api_label} API] Ask a natural-language question about the configured API.\n\n" + "Use when the client needs fresh API data, joins, filtering, ranking, or SQL-style " + "post-processing. The agent reads the API schema, calls the target API, and returns " + "the answer plus calls made." + ) + + +def _openapi_info_description(raw_schema: str) -> str: + if not raw_schema: + return "" + try: + schema = json.loads(raw_schema) + except ValueError: + return "" + if not isinstance(schema, dict): + return "" + info = schema.get("info") + if not isinstance(info, dict): + return "" + description = info.get("description") + return _clean_fallback_description(description) if isinstance(description, str) else "" + + +def _clean_fallback_description(text: str) -> str: + normalized = " ".join(text.split()) + normalized = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", normalized) + normalized = re.sub(r"[`*_>#]+", "", normalized).strip() + if len(normalized) <= _MAX_FALLBACK_DESCRIPTION_CHARS: + return normalized + return _first_sentence(normalized) + + +def _graphql_schema_description(raw_schema: str, hostname: str) -> str: + if not raw_schema: + return "" + try: + context = _graphql_description_context(json.loads(raw_schema)) + except ValueError: + return "" + + query_fields = context.get("query_fields") or [] + domain_types = context.get("domain_types") or [] + field_names = [ + str(field["name"]) + for field in query_fields[:4] + if isinstance(field, dict) and field.get("name") + ] + descriptions = [ + _first_sentence(str(item["description"])) + for item in [*query_fields, *_useful_graphql_domain_types(domain_types)] + if isinstance(item, dict) and isinstance(item.get("description"), str) + ] + + if descriptions: + return ". ".join(descriptions[:2]) + "." + if field_names: + return f"Ask about {hostname} data including {', '.join(field_names)}." + return "" + + +def _useful_graphql_domain_types(domain_types: Any) -> list[dict[str, Any]]: + if not isinstance(domain_types, list): + return [] + useful = [] + for item in domain_types: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").lower() + if any(part in name for part in _GENERIC_GRAPHQL_TYPE_PARTS): + continue + useful.append(item) + return sorted(useful, key=_graphql_domain_type_rank) + + +def _first_sentence(text: str) -> str: + normalized = " ".join(text.split()) + sentence = normalized.split(". ", 1)[0].strip().rstrip(".") + return sentence[:240] + + +def _graphql_domain_type_rank(item: dict[str, Any]) -> tuple[int, str]: + name = str(item.get("name") or "").lower() + for index, priority_name in enumerate(_GRAPHQL_DOMAIN_TYPE_PRIORITY): + if name == priority_name: + return index, name + return len(_GRAPHQL_DOMAIN_TYPE_PRIORITY), name + + +def _fallback_result(*, api_type: str, hostname: str, raw_schema: str) -> DescriptionResult: + return DescriptionResult( + text=fallback_downstream_description( + api_type=api_type, + hostname=hostname, + raw_schema=raw_schema, + ), + ttl_seconds=_FALLBACK_CACHE_TTL_SECONDS, + ) + + +async def _generate_downstream_description( + *, + api_type: str, + hostname: str, + raw_schema: str, +) -> DescriptionResult: + from .agent.model import client, create_openai_model, get_run_config + from .agent.progress import reset_progress + + model_name = settings.DESCRIPTION_MODEL_NAME or settings.MODEL_NAME + agent = Agent( + name="downstream-description-generator", + model=create_openai_model(settings.MODEL_API, model_name, client), + instructions=DESCRIPTION_INSTRUCTIONS, + tools=[], + output_type=AgentOutputSchema(DownstreamDescriptionOutput, strict_json_schema=False), + ) + payload = { + "api_type": api_type, + "hostname": hostname, + "schema_summary": build_description_context(api_type=api_type, raw_schema=raw_schema), + } + try: + reset_progress() + result = await Runner.run( + agent, + json.dumps(payload, indent=2), + max_turns=1, + run_config=get_run_config(), + ) + except ModelBehaviorError: + logger.info("Invalid downstream description model output") + return _fallback_result(api_type=api_type, hostname=hostname, raw_schema=raw_schema) + + output = result.final_output + if isinstance(output, dict): + output = DownstreamDescriptionOutput.model_validate(output) + if not isinstance(output, DownstreamDescriptionOutput): + return _fallback_result(api_type=api_type, hostname=hostname, raw_schema=raw_schema) + return _clean_description_result( + description=output.description, + api_type=api_type, + hostname=hostname, + raw_schema=raw_schema, + ) + + +def build_description_context(*, api_type: str, raw_schema: str) -> dict[str, Any]: + try: + schema = json.loads(raw_schema) + except ValueError: + return {"raw_schema": raw_schema[:_MAX_DESCRIPTION_CONTEXT_CHARS]} + + if api_type == "graphql": + return _graphql_description_context(schema) + return _openapi_description_context(schema) + + +def _graphql_description_context(schema: Any) -> dict[str, Any]: + if not isinstance(schema, dict): + return {"raw_schema": _truncate_json(schema)} + + types = schema.get("types", []) + type_by_name = {t.get("name"): t for t in types if isinstance(t, dict)} + query_type = schema.get("queryType") or {} + mutation_type = schema.get("mutationType") or {} + + return { + "query_fields": _graphql_fields(type_by_name.get(query_type.get("name"))), + "mutation_fields": _graphql_fields(type_by_name.get(mutation_type.get("name"))), + "domain_types": _graphql_domain_types( + types, + root_names={query_type.get("name"), mutation_type.get("name")}, + ), + } + + +def _graphql_fields(type_def: Any) -> list[dict[str, Any]]: + if not isinstance(type_def, dict): + return [] + fields = type_def.get("fields") or [] + items = [] + for field in fields[:_MAX_FIELD_COUNT]: + if not isinstance(field, dict): + continue + items.append( + { + "name": field.get("name"), + "description": field.get("description"), + "args": [ + arg.get("name") + for arg in field.get("args") or [] + if isinstance(arg, dict) and arg.get("name") + ], + } + ) + return items + + +def _graphql_domain_types(types: Any, *, root_names: set[Any]) -> list[dict[str, Any]]: + if not isinstance(types, list): + return [] + domain_types = [] + for type_def in types: + if not isinstance(type_def, dict): + continue + name = type_def.get("name") + if not name or name in root_names or str(name).startswith("__"): + continue + fields = type_def.get("fields") or [] + if not fields: + continue + domain_types.append( + { + "name": name, + "description": type_def.get("description"), + "fields": [ + field.get("name") + for field in fields[:12] + if isinstance(field, dict) and field.get("name") + ], + } + ) + if len(domain_types) >= _MAX_FIELD_COUNT: + break + return domain_types + + +def _openapi_description_context(schema: Any) -> dict[str, Any]: + if not isinstance(schema, dict): + return {"raw_schema": _truncate_json(schema)} + + paths = [] + for path, path_spec in (schema.get("paths") or {}).items(): + if len(paths) >= _MAX_PATH_COUNT: + break + if not isinstance(path_spec, dict): + continue + for method, operation in path_spec.items(): + if len(paths) >= _MAX_PATH_COUNT: + break + if method.lower() not in {"get", "post", "put", "patch", "delete"}: + continue + if not isinstance(operation, dict): + continue + paths.append( + { + "method": method.upper(), + "path": path, + "summary": operation.get("summary"), + "description": operation.get("description"), + "operation_id": operation.get("operationId"), + "tags": operation.get("tags") or [], + } + ) + + return { + "title": (schema.get("info") or {}).get("title"), + "description": (schema.get("info") or {}).get("description"), + "version": (schema.get("info") or {}).get("version"), + "tags": schema.get("tags") or [], + "paths": paths, + } + + +def _clean_description_result( + *, description: str, api_type: str, hostname: str, raw_schema: str +) -> DescriptionResult: + text = " ".join(description.split()) + lowered = text.lower() + forbidden = ("api agent", "wrapper", "mcp") + if len(text) < 40 or any(term in lowered for term in forbidden): + return _fallback_result(api_type=api_type, hostname=hostname, raw_schema=raw_schema) + return DescriptionResult(text=text[:500]) + + +def _truncate_json(value: Any) -> str: + return json.dumps(value, sort_keys=True, default=str)[:_MAX_DESCRIPTION_CONTEXT_CHARS] diff --git a/api_agent/executor.py b/api_agent/executor.py index a8f188a..ab00441 100644 --- a/api_agent/executor.py +++ b/api_agent/executor.py @@ -63,10 +63,11 @@ def _extract_schema(data: list[dict], table_name: str) -> dict[str, Any]: json.dump(data, f) temp_file = f.name - conn = duckdb.connect() - conn.execute(f"CREATE TABLE {table_name} AS SELECT * FROM read_json_auto('{temp_file}')") - schema = conn.execute(f"DESCRIBE {table_name}").fetchall() - conn.close() + with duckdb.connect() as conn: + conn.execute( + f"CREATE TABLE {table_name} AS SELECT * FROM read_json_auto('{temp_file}')" + ) + schema = conn.execute(f"DESCRIBE {table_name}").fetchall() schema_str = ", ".join([f"{col[0]}: {col[1]}" for col in schema]) @@ -131,10 +132,22 @@ def truncate_for_context( } -# Keep for backwards compatibility -def get_table_schema_summary(data: list[dict], table_name: str) -> dict[str, Any]: - """Get DuckDB schema summary (deprecated, use extract_tables_from_response).""" - return _extract_schema(data, table_name) +def _query_result_rows(conn: duckdb.DuckDBPyConnection, query: str) -> list[dict[str, Any]]: + temp_file = None + try: + conn.sql(query).create("__sql_result") + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + temp_file = f.name + conn.execute("COPY __sql_result TO ? (FORMAT json, ARRAY true)", [temp_file]) + with open(temp_file) as f: + rows = json.load(f) + return rows if isinstance(rows, list) else [] + finally: + if temp_file: + try: + os.unlink(temp_file) + except OSError: + pass def execute_sql(data: Any, query: str) -> dict[str, Any]: @@ -149,31 +162,28 @@ def execute_sql(data: Any, query: str) -> dict[str, Any]: """ temp_files = [] try: - conn = duckdb.connect() - - # Register top-level keys as tables via temp JSON files - if isinstance(data, dict): - for key, value in data.items(): - if isinstance(value, list) and value: - # Write to temp file for DuckDB to read - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(value, f) - temp_files.append(f.name) - conn.execute(f"CREATE TABLE {key} AS SELECT * FROM read_json_auto('{f.name}')") - elif isinstance(data, list): - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(data, f) - temp_files.append(f.name) - conn.execute(f"CREATE TABLE data AS SELECT * FROM read_json_auto('{f.name}')") - - # Execute query - result = conn.execute(query).fetchall() - columns = [desc[0] for desc in conn.description or []] - - # Convert to list of dicts - rows = [dict(zip(columns, row)) for row in result] - - conn.close() + with duckdb.connect() as conn: + # Register top-level keys as tables via temp JSON files + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, list) and value: + # Write to temp file for DuckDB to read + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as f: + json.dump(value, f) + temp_files.append(f.name) + conn.execute( + f"CREATE TABLE {key} AS SELECT * FROM read_json_auto('{f.name}')" + ) + elif isinstance(data, list): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + temp_files.append(f.name) + conn.execute(f"CREATE TABLE data AS SELECT * FROM read_json_auto('{f.name}')") + + rows = _query_result_rows(conn, query) + return {"success": True, "result": rows} except duckdb.Error as e: @@ -182,7 +192,6 @@ def execute_sql(data: Any, query: str) -> dict[str, Any]: logger.exception("SQL execution error") return {"success": False, "error": str(e)} finally: - # Cleanup temp files for temp_file in temp_files: try: os.unlink(temp_file) diff --git a/api_agent/graphql/client.py b/api_agent/graphql/client.py index 27871af..27f9a68 100644 --- a/api_agent/graphql/client.py +++ b/api_agent/graphql/client.py @@ -6,8 +6,6 @@ import httpx -from ..utils.http_errors import build_http_error_response - logger = logging.getLogger(__name__) # Block mutations (read-only mode) @@ -59,7 +57,7 @@ async def execute_query( return {"success": False, "error": result["errors"]} return {"success": True, "data": result.get("data", {})} except httpx.HTTPStatusError as e: - return build_http_error_response(e) + return {"success": False, "error": f"HTTP {e.response.status_code}"} except Exception as e: logger.exception("GraphQL error") return {"success": False, "error": str(e)} diff --git a/api_agent/graphql/schema_context.py b/api_agent/graphql/schema_context.py new file mode 100644 index 0000000..3bf35a9 --- /dev/null +++ b/api_agent/graphql/schema_context.py @@ -0,0 +1,105 @@ +"""GraphQL introspection schema context formatting.""" + +from __future__ import annotations + + +def format_type(t: dict | None) -> str: + """Convert introspection type to compact notation: [User!]!""" + if not t: + return "?" + kind = t.get("kind") + name = t.get("name") + inner = t.get("ofType") + + if kind == "NON_NULL": + return f"{format_type(inner)}!" + if kind == "LIST": + return f"[{format_type(inner)}]" + return name or "?" + + +def is_required(type_def: dict | None) -> bool: + """Check if GraphQL type is required (NON_NULL wrapper).""" + return type_def.get("kind") == "NON_NULL" if type_def else False + + +def format_arg(arg: dict) -> str: + """Format argument with optional default value.""" + type_str = format_type(arg["type"]) + default = arg.get("defaultValue") + if default is not None: + return f"{arg['name']}: {type_str} = {default}" + return f"{arg['name']}: {type_str}" + + +def filter_required_args(args: list[dict]) -> list[dict]: + """Filter to only required arguments.""" + return [arg for arg in args if is_required(arg.get("type"))] + + +def format_field(field: dict) -> str: + """Format a field with optional args.""" + args = field.get("args", []) + arg_str = "(" + ", ".join(format_arg(arg) for arg in args) + ")" if args else "" + desc = f" # {field['description']}" if field.get("description") else "" + return f" {field['name']}{arg_str}: {format_type(field['type'])}{desc}" + + +def build_schema_context(schema: dict) -> str: + """Build compact SDL context from introspection schema.""" + queries = schema.get("queryType", {}).get("fields", []) + all_types = [t for t in schema.get("types", []) if not t["name"].startswith("__")] + + objects = [ + t + for t in all_types + if t["kind"] == "OBJECT" and t["name"] not in ("Query", "Mutation", "Subscription") + ] + enums = [t for t in all_types if t["kind"] == "ENUM"] + inputs = [t for t in all_types if t["kind"] == "INPUT_OBJECT"] + interfaces = [t for t in all_types if t["kind"] == "INTERFACE"] + unions = [t for t in all_types if t["kind"] == "UNION"] + + lines = [""] + for field in queries: + desc = f" # {field['description']}" if field.get("description") else "" + args = ", ".join(format_arg(arg) for arg in filter_required_args(field.get("args", []))) + lines.append(f"{field['name']}({args}) -> {format_type(field['type'])}{desc}") + + if interfaces: + lines.append("\n") + for typ in interfaces: + impl = [p["name"] for p in typ.get("possibleTypes", []) or []] + impl_str = f" # implemented by: {', '.join(impl)}" if impl else "" + fields = [format_field(field) for field in typ.get("fields", []) or []] + lines.append(f"{typ['name']} {{{impl_str}\n" + "\n".join(fields) + "\n}") + + if unions: + lines.append("\n") + for typ in unions: + types = [p["name"] for p in typ.get("possibleTypes", []) or []] + lines.append(f"{typ['name']}: {' | '.join(types)}") + + lines.append("\n") + for typ in objects: + impl = [i["name"] for i in typ.get("interfaces", []) or []] + impl_str = f" implements {', '.join(impl)}" if impl else "" + fields = [format_field(field) for field in typ.get("fields", []) or []] + lines.append(f"{typ['name']}{impl_str} {{\n" + "\n".join(fields) + "\n}") + + lines.append("\n") + for enum in enums: + vals = " | ".join(v["name"] for v in enum.get("enumValues", [])) + lines.append(f"{enum['name']}: {vals}") + + lines.append("\n") + for inp in inputs: + required_fields = [ + field for field in (inp.get("inputFields", []) or []) if is_required(field.get("type")) + ] + fields = ", ".join( + f"{field['name']}: {format_type(field['type'])}" for field in required_fields + ) + lines.append(f"{inp['name']} {{ {fields} }}") + + return "\n".join(lines) diff --git a/api_agent/middleware.py b/api_agent/middleware.py index 06620d0..ed81fc7 100644 --- a/api_agent/middleware.py +++ b/api_agent/middleware.py @@ -7,24 +7,36 @@ from fastmcp.exceptions import NotFoundError, ToolError, ValidationError from fastmcp.server.dependencies import get_http_headers from fastmcp.server.middleware import Middleware, MiddlewareContext -from fastmcp.tools.tool import Tool as FastMCPTool -from fastmcp.tools.tool import ToolResult +from fastmcp.tools import Tool as FastMCPTool +from fastmcp.tools import ToolResult from mcp import types as mt from mcp.types import TextContent from .config import settings from .context import MissingHeaderError, extract_api_name, get_full_hostname, get_request_context -from .recipe import build_api_id, build_recipe_docstring -from .recipe.common import create_params_model +from .description import get_downstream_description +from .recipe.contracts import ( + get_recipe_description, + get_recipe_tool_args, + get_recipe_tool_name, + has_recipe_contract, +) from .recipe.naming import sanitize_tool_name from .recipe.runner import execute_recipe_tool, load_schema_and_base_url -from .recipe.store import RECIPE_STORE, sha256_hex +from .recipe.search import build_api_id +from .recipe.tooling import build_recipe_docstring, create_params_model +from .store import ASYNC_API_AGENT_STORE, sha256_hex # Internal tool name suffix pattern INTERNAL_TOOL_PATTERN = re.compile(r"^_(.+)$") -MAX_TOOL_NAME_LEN = 60 +# Keep exposed recipe tool names below 50 chars including "r_". +MAX_TOOL_NAME_LEN = 49 RECIPE_NAME_PREFIX = "r" RECIPE_PREFIX_STR = f"{RECIPE_NAME_PREFIX}_" +SPECIFIC_TOOL_HINT = ( + "If another listed tool directly matches the request, use that specific tool before this " + "general question tool." +) def _get_tool_suffix(internal_name: str) -> str: @@ -33,11 +45,16 @@ def _get_tool_suffix(internal_name: str) -> str: return match.group(1) if match else internal_name -def _inject_api_context(description: str, hostname: str, api_type: str) -> str: - """Inject API context into tool description using full hostname.""" - api_type_label = "GraphQL" if api_type == "graphql" else "REST" - prefix = f"[{hostname} {api_type_label} API] " - return prefix + description +def _prefer_specific_tools(description: str) -> str: + """Add a model-facing hint when specific tools are available.""" + if SPECIFIC_TOOL_HINT in description: + return description + return f"{description.rstrip()}\n\n{SPECIFIC_TOOL_HINT}" + + +def _tool_title(name: str) -> str: + """Build a compact human title from a tool name.""" + return " ".join(part for part in name.replace("_", " ").split()).title() def _max_slug_length() -> int: @@ -57,14 +74,12 @@ def _build_recipe_tool_name(slug: str) -> str: def _build_recipe_input_schema(params_spec: dict, tool_name: str) -> dict: """Build flat JSON Schema for recipe tool input. - All declared params are top-level required fields (no defaults). + All public tool args are top-level required fields. Uses Pydantic ``create_params_model`` for schema generation. """ Model = create_params_model(params_spec, tool_name) schema = Model.model_json_schema() - # Add return_directly as optional top-level field - schema["properties"]["return_directly"] = {"type": "boolean", "default": True} schema.pop("title", None) schema["additionalProperties"] = False @@ -72,7 +87,6 @@ def _build_recipe_input_schema(params_spec: dict, tool_name: str) -> dict: async def _list_recipe_tools( - hostname: str, req_ctx, raw_schema: str, base_url: str, @@ -85,14 +99,19 @@ async def _list_recipe_tools( schema_hash = sha256_hex(raw_schema) api_id = build_api_id(req_ctx, req_ctx.api_type, base_url) - recipes = RECIPE_STORE.list_recipes(api_id=api_id, schema_hash=schema_hash) + recipes = await ASYNC_API_AGENT_STORE.list_recipes( + api_id=api_id, + schema_hash=schema_hash, + ) tools: list[FastMCPTool] = [] # Group by tool slug (truncated to fit name) and pick most recent max_slug_len = _max_slug_length() by_slug: dict[str, list[dict]] = {} for r in recipes: - name = r.get("tool_name") or "recipe" + if not has_recipe_contract(r): + continue + name = get_recipe_tool_name(r) or "recipe" slug = sanitize_tool_name(name)[:max_slug_len] by_slug.setdefault(slug, []).append(r) @@ -100,23 +119,28 @@ async def _list_recipe_tools( group.sort(key=lambda r: (r.get("last_used_at", 0), r.get("created_at", 0)), reverse=True) r = group[0] tool_name = _build_recipe_tool_name(slug) - params_spec = r.get("params", {}) or {} + params_spec = get_recipe_tool_args(r) + description = get_recipe_description(r) + if not description.strip(): + continue desc = build_recipe_docstring( r.get("question", ""), - r.get("steps", []), - r.get("sql_steps", []), + [], req_ctx.api_type, params_spec, + description=description, ) - desc += f"\nRecipe Name: {r.get('tool_name') or 'recipe'}\n" - if len(group) > 1: - desc += f"Note: {len(group)} recipes share this name; using most recent.\n" - description = _inject_api_context(desc, hostname, req_ctx.api_type) + title = _tool_title(get_recipe_tool_name(r) or slug) tools.append( FastMCPTool( name=tool_name, - description=description, + title=title, + description=desc, parameters=_build_recipe_input_schema(params_spec, slug), + annotations=mt.ToolAnnotations( + title=title, + openWorldHint=True, + ), tags={"recipe"}, ) ) @@ -154,23 +178,53 @@ async def on_list_tools( ) target_url = headers.get("x-target-url", "") - api_type = headers.get("x-api-type", "api") - # Short prefix for tool name, full hostname for description name_prefix = extract_api_name(headers) full_hostname = get_full_hostname(target_url) + schema_hash = sha256_hex(raw_schema) + api_id = build_api_id(req_ctx, req_ctx.api_type, base_url) + downstream_description = await get_downstream_description( + api_type=req_ctx.api_type, + hostname=full_hostname, + raw_schema=raw_schema, + api_id=api_id, + schema_hash=schema_hash, + ) + recipe_tools = await _list_recipe_tools(req_ctx, raw_schema, base_url) + has_specific_tools = bool(recipe_tools) transformed = [] for tool in tools: suffix = _get_tool_suffix(tool.name) + primary_prefix = f"{name_prefix}_" + alt_prefix = f"{name_prefix.replace('-', '_')}_" + if suffix.startswith(primary_prefix): + suffix = suffix.removeprefix(primary_prefix) + elif suffix.startswith(alt_prefix): + suffix = suffix.removeprefix(alt_prefix) new_name = f"{name_prefix}_{suffix}" - new_desc = _inject_api_context(tool.description or "", full_hostname, api_type) - - modified_tool = tool.model_copy(update={"name": new_name, "description": new_desc}) + if suffix == "query": + new_desc = downstream_description + if has_specific_tools: + new_desc = _prefer_specific_tools(new_desc) + else: + new_desc = tool.description or "" + title = _tool_title(new_name) + + modified_tool = tool.model_copy( + update={ + "name": new_name, + "title": title, + "description": new_desc, + "annotations": mt.ToolAnnotations( + title=title, + openWorldHint=True, + ), + } + ) transformed.append(modified_tool) - recipe_tools = await _list_recipe_tools(full_hostname, req_ctx, raw_schema, base_url) - return [*transformed, *recipe_tools] + return [*transformed, *sorted(recipe_tools, key=lambda tool: tool.name)] async def on_call_tool( self, @@ -199,7 +253,6 @@ async def on_call_tool( if not isinstance(arguments, dict): raise ValidationError("Invalid arguments: expected object.") - return_directly = bool(arguments.get("return_directly", True)) params = {k: v for k, v in arguments.items() if k != "return_directly"} or None raw_schema, base_url = await load_schema_and_base_url(req_ctx) @@ -208,7 +261,7 @@ async def on_call_tool( schema_hash = sha256_hex(raw_schema) api_id = build_api_id(req_ctx, req_ctx.api_type, base_url) - recipe_meta = RECIPE_STORE.find_recipe_by_tool_slug( + recipe_meta = await ASYNC_API_AGENT_STORE.find_recipe_by_tool_slug( api_id=api_id, schema_hash=schema_hash, tool_slug=recipe_slug, @@ -222,7 +275,7 @@ async def on_call_tool( req_ctx, recipe_id, params, - return_directly, + True, raw_schema=raw_schema, base_url=base_url, ) @@ -236,7 +289,7 @@ async def on_call_tool( if isinstance(parsed, dict) and parsed.get("success") is False: err_msg = parsed.get("error", "recipe execution failed") if isinstance(err_msg, str) and err_msg.startswith( - ("missing required param:", "unexpected params:") + ("missing required param:", "unexpected params:", "invalid param type:") ): raise ValidationError(err_msg) raise ToolError(err_msg) @@ -254,6 +307,10 @@ async def on_call_tool( # Transform back to internal name (_suffix) suffix = tool_name.removeprefix(expected_prefix) + if not suffix: + raise NotFoundError( + f"Tool '{tool_name}' not valid for API '{api_name}'. Missing tool suffix." + ) internal_name = f"_{suffix}" # Create modified context with internal tool name diff --git a/api_agent/query_response.py b/api_agent/query_response.py new file mode 100644 index 0000000..cc1e476 --- /dev/null +++ b/api_agent/query_response.py @@ -0,0 +1,60 @@ +"""Query response envelope for MCP output formatting.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class QueryResponse: + """Normalized agent response before transport-specific formatting.""" + + ok: bool + data: Any = None + result: Any = None + calls: list[Any] = field(default_factory=list) + calls_key: str = "calls" + error: Any = None + trace_id: str | None = None + + @classmethod + def from_agent_result(cls, result: dict[str, Any], calls_key: str) -> "QueryResponse": + """Build from the existing agent result dict contract.""" + calls = result.get(calls_key, []) + trace_id = result.get("trace_id") + return cls( + ok=bool(result.get("ok", False)), + data=result.get("data"), + result=result.get("result"), + calls=list(calls) if isinstance(calls, list) else [], + calls_key=calls_key, + error=result.get("error"), + trace_id=trace_id if isinstance(trace_id, str) else None, + ) + + @property + def should_return_csv(self) -> bool: + """Current direct-return behavior: result present with no text answer.""" + return self.result is not None and self.data is None + + def to_mcp_payload( + self, + *, + include_result: bool = False, + include_debug: bool = False, + ) -> dict[str, Any]: + """Convert to the existing MCP response dict shape.""" + payload = { + "ok": self.ok, + "data": self.data, + "error": self.error, + } + if include_result and self.result is not None: + payload["result"] = self.result + if include_debug: + debug: dict[str, Any] = {self.calls_key: self.calls} + if self.trace_id: + debug["trace_id"] = self.trace_id + payload["debug"] = debug + return payload diff --git a/api_agent/recipe/__init__.py b/api_agent/recipe/__init__.py index 87b87f1..67ce8da 100644 --- a/api_agent/recipe/__init__.py +++ b/api_agent/recipe/__init__.py @@ -1,64 +1 @@ -"""Recipe module for parameterized API-call + SQL pipeline caching.""" - -from .common import ( - _return_directly_flag, - _sanitize_for_tool_name, - _set_return_directly, - _tools_to_final_output, - build_api_id, - build_partial_result, - build_recipe_context, - build_recipe_docstring, - consume_recipe_changes, - create_params_model, - deduplicate_tool_name, - execute_recipe_steps, - format_recipe_response, - maybe_extract_and_save_recipe, - reset_recipe_change_flag, - search_recipes, - validate_and_prepare_recipe, - validate_recipe_params, -) -from .extractor import extract_recipe -from .store import ( - RECIPE_STORE, - RecipeRecord, - RecipeStore, - get_example_values, - render_param_refs, - render_text_template, - sha256_hex, -) - -__all__ = [ - # Store - "RECIPE_STORE", - "_return_directly_flag", - "_sanitize_for_tool_name", - "_set_return_directly", - "_tools_to_final_output", - "RecipeStore", - "RecipeRecord", - "sha256_hex", - "render_text_template", - "render_param_refs", - "get_example_values", - # Extractor - "extract_recipe", - # Common - "build_recipe_docstring", - "consume_recipe_changes", - "create_params_model", - "deduplicate_tool_name", - "execute_recipe_steps", - "format_recipe_response", - "maybe_extract_and_save_recipe", - "build_partial_result", - "build_api_id", - "build_recipe_context", - "reset_recipe_change_flag", - "search_recipes", - "validate_and_prepare_recipe", - "validate_recipe_params", -] +"""Recipe learning and execution package.""" diff --git a/api_agent/recipe/common.py b/api_agent/recipe/common.py deleted file mode 100644 index d45d785..0000000 --- a/api_agent/recipe/common.py +++ /dev/null @@ -1,543 +0,0 @@ -"""Shared utilities for recipe tools in GraphQL and REST agents.""" - -import json -import logging -import re -from contextvars import ContextVar -from typing import Any - -from agents import FunctionToolResult, RunContextWrapper -from agents.agent import ToolsToFinalOutputResult -from pydantic import BaseModel, ConfigDict, Field, create_model - -from ..config import settings -from ..executor import execute_sql, truncate_for_context -from .extractor import extract_recipe -from .store import RECIPE_STORE, render_text_template, sha256_hex - -logger = logging.getLogger(__name__) - -# Mapping from recipe param type names to JSON Schema type names -_JSON_TYPE_NAMES = {"str": "string", "int": "integer", "float": "number", "bool": "boolean"} - -# Track recipe changes per-request for tool list changed notifications -_recipes_changed: ContextVar[list[str]] = ContextVar("recipes_changed") - - -def reset_recipe_change_flag() -> None: - """Reset recipe change tracking for the current request.""" - _recipes_changed.set([]) - - -def mark_recipe_changed(recipe_id: str) -> None: - """Record that a recipe was created during the current request.""" - try: - _recipes_changed.get().append(recipe_id) - except LookupError: - _recipes_changed.set([recipe_id]) - - -def consume_recipe_changes() -> list[str]: - """Consume and clear recipe change tracking.""" - try: - changes = list(_recipes_changed.get()) - except LookupError: - return [] - _recipes_changed.set([]) - return changes - - -def _normalize_ws_value(value: Any) -> Any: - if isinstance(value, str): - return re.sub(r"\s+", " ", value).strip() - return value - - -def _recipes_equivalent(existing: dict[str, Any], candidate: dict[str, Any], api_type: str) -> bool: - """Check if two recipes are equivalent for the same API type.""" - if existing.get("params", {}) != candidate.get("params", {}): - return False - - e_steps = existing.get("steps", []) - c_steps = candidate.get("steps", []) - e_sql = existing.get("sql_steps", []) - c_sql = candidate.get("sql_steps", []) - if not (isinstance(e_steps, list) and isinstance(c_steps, list)): - return False - if not (isinstance(e_sql, list) and isinstance(c_sql, list)): - return False - if len(e_steps) != len(c_steps) or len(e_sql) != len(c_sql): - return False - - for e_step, c_step in zip(e_steps, c_steps): - if not (isinstance(e_step, dict) and isinstance(c_step, dict)): - return False - if e_step.get("kind") != c_step.get("kind"): - return False - if e_step.get("name") != c_step.get("name"): - return False - if api_type == "graphql": - if _normalize_ws_value(e_step.get("query_template")) != _normalize_ws_value( - c_step.get("query_template") - ): - return False - else: - for key in ("method", "path", "path_params", "query_params", "body"): - if e_step.get(key) != c_step.get(key): - return False - - for e_sql_step, c_sql_step in zip(e_sql, c_sql): - if _normalize_ws_value(e_sql_step) != _normalize_ws_value(c_sql_step): - return False - - return True - - -async def maybe_extract_and_save_recipe( - api_type: str, - api_id: str, - question: str, - steps: list, - sql_steps: list[str], - raw_schema: str, - skip_condition: bool = False, -) -> None: - """Extract and save recipe if conditions met. - - Args: - api_type: "graphql" or "rest" - api_id: API identifier for recipe storage - question: Original user question - steps: API call steps from agent execution - sql_steps: SQL steps from agent execution - raw_schema: Raw schema string for hash - skip_condition: If True, skip extraction (e.g., polling used) - """ - if not settings.ENABLE_RECIPES: - return - if skip_condition: - logger.info("Skipping recipe extraction (skip condition)") - return - if not (steps and raw_schema): - return - - try: - schema_hash = sha256_hex(raw_schema) - existing_recipes = RECIPE_STORE.list_recipes(api_id=api_id, schema_hash=schema_hash) - recipe = await extract_recipe( - api_type=api_type, - question=question, - steps=steps, - sql_steps=sql_steps, - existing_recipes=existing_recipes, - ) - if recipe: - # Skip if recipe already exists (same steps/sql/params) - for existing in existing_recipes: - if _recipes_equivalent(existing, recipe, api_type): - return - - # Ensure tool_name does not collide with existing recipes - seen: set[str] = {r["tool_name"] for r in existing_recipes if r.get("tool_name")} - recipe["tool_name"] = deduplicate_tool_name( - recipe.get("tool_name", ""), seen_names=seen, max_len=40 - ) - tool_name = recipe.get("tool_name", "") - recipe_id = RECIPE_STORE.save_recipe( - api_id=api_id, - schema_hash=schema_hash, - question=question, - recipe=recipe, - tool_name=tool_name, - ) - mark_recipe_changed(recipe_id) - except Exception: - logger.exception("Recipe extraction failed") - - -# Shared ContextVar for direct return signaling -_return_directly_flag: ContextVar[list[bool]] = ContextVar("return_directly_flag") - - -def _set_return_directly() -> None: - """Signal that tool result should be returned directly (skip LLM).""" - try: - _return_directly_flag.get().append(True) - except LookupError: - pass - - -def _tools_to_final_output( - context: RunContextWrapper[Any], tool_results: list[FunctionToolResult] -) -> ToolsToFinalOutputResult: - """Check if any tool requested direct return (skip LLM processing).""" - try: - if _return_directly_flag.get(): - return ToolsToFinalOutputResult(is_final_output=True, final_output="__DIRECT_RETURN__") - except LookupError: - pass - return ToolsToFinalOutputResult(is_final_output=False, final_output=None) - - -def build_recipe_docstring( - question: str, - steps: list, - sql_steps: list, - api_type: str = "rest", - params_spec: dict[str, Any] | None = None, -) -> str: - """Build docstring for recipe tool.""" - parts = [] - if steps: - count = len(steps) - if api_type == "graphql": - parts.append(f"{count} GraphQL quer{'ies' if count > 1 else 'y'}") - else: - parts.append(f"{count} API call{'s' if count > 1 else ''}") - if sql_steps: - parts.append(f"{len(sql_steps)} SQL step{'s' if len(sql_steps) > 1 else ''}") - steps_summary = " + ".join(parts) if parts else "No steps" - - params_section = "" - if params_spec: - param_lines = [] - for pname, spec in params_spec.items(): - ptype = _JSON_TYPE_NAMES.get( - spec.get("type", "str") if isinstance(spec, dict) else "str", "string" - ) - example = spec.get("default") if isinstance(spec, dict) else None - hint = f" (e.g. {example})" if example is not None else "" - param_lines.append(f" {pname}: {ptype} REQUIRED{hint}") - params_section = "\nRequired params:\n" + "\n".join(param_lines) - - return f"Execute recipe: {question}\nRecipe performs: {steps_summary}{params_section}" - - -def create_params_model(pspec: dict[str, Any], tname: str): - """Create Pydantic model for recipe params with strict validation. - - All fields are required (no defaults). Stored defaults are example values - from the original execution and are shown as description hints only. - """ - - class StrictBase(BaseModel): - model_config = ConfigDict(extra="forbid") - - type_map = {"str": str, "int": int, "float": float, "bool": bool} - field_defs = {} - for pname, pinfo in pspec.items(): - py_type = type_map.get(pinfo.get("type", "str"), str) - example = pinfo.get("default") - desc = f"Required. e.g. {example}" if example is not None else "Required" - field_defs[pname] = (py_type, Field(..., description=desc)) - - return create_model(f"{tname}_Params", __base__=StrictBase, **field_defs) - - -def deduplicate_tool_name(base_name: str, seen_names: set[str], max_len: int = 40) -> str: - """Ensure unique tool name within length limit.""" - base = re.sub(r"[^a-z0-9_]", "", base_name)[:max_len] - if not base or not re.match(r"^[a-z][a-z0-9_]*$", base): - base = "recipe" - - if base not in seen_names: - seen_names.add(base) - return base - - counter = 2 - while True: - suffix = f"_{counter}" - trimmed = base[: max_len - len(suffix)] - candidate = f"{trimmed}{suffix}" - if candidate not in seen_names: - seen_names.add(candidate) - return candidate - counter += 1 - - -def _execute_sql_steps( - sql_steps: list[str], - params: dict[str, Any], - results: dict[str, Any], - last_result_var: ContextVar[list[Any]], -) -> tuple[bool, list[str], str]: - """Execute SQL steps. Returns (success, executed_sql, error_json).""" - executed_sql: list[str] = [] - - for sql_tmpl in sql_steps: - if not isinstance(sql_tmpl, str): - return ( - False, - executed_sql, - json.dumps({"success": False, "error": "invalid sql_steps"}, indent=2), - ) - - sql = render_text_template(sql_tmpl, params) - res = execute_sql(results, sql) - executed_sql.append(sql) - - if not res.get("success"): - return False, executed_sql, json.dumps(res, indent=2) - - try: - last_result_var.get()[0] = res.get("result", []) - except LookupError: - pass - - return True, executed_sql, "" - - -def format_recipe_response( - last_result_var: ContextVar[list[Any]], - executed_items: list[Any], - executed_sql: list[str], - item_key: str, -) -> str: - """Format recipe JSON response with truncation.""" - try: - last_rows = last_result_var.get()[0] - except LookupError: - last_rows = None - - base = {"success": True, item_key: executed_items, "executed_sql": executed_sql} - if isinstance(last_rows, list): - base.update(truncate_for_context(last_rows, "sql_result")) - return json.dumps(base, indent=2) - - -def build_partial_result( - last_data: Any, - api_calls: list[Any], - turn_info: str, - call_key: str, -) -> dict[str, Any]: - """Build partial result dict for MaxTurnsExceeded.""" - if last_data: - return { - "ok": True, - "data": f"[Partial - {turn_info}] Max turns exceeded but data retrieved.", - "result": last_data, - call_key: api_calls, - "error": None, - } - return { - "ok": False, - "data": None, - "result": None, - call_key: api_calls, - "error": f"Max turns exceeded ({turn_info}), no data retrieved", - } - - -def build_api_id(ctx, api_type: str, base_url: str = "") -> str: - """Build api_id string for recipe matching.""" - if api_type == "graphql": - return f"graphql:{ctx.target_url}" - return f"rest:{ctx.target_url}|{base_url}" - - -def _get_results_context(query_results_var: ContextVar[dict[str, Any]]) -> dict[str, Any]: - """Get or create results dict from ContextVar.""" - try: - return query_results_var.get() - except LookupError: - results: dict[str, Any] = {} - query_results_var.set(results) - return results - - -def _score_hint(score: float) -> str: - """Get human-readable hint for recipe match score.""" - if score >= 0.8: - return "STRONG MATCH - highly recommended" - if score >= 0.6: - return "Good match - verify params" - return "Possible match - check alignment" - - -def _steps_summary(steps: list, sql_steps: list) -> str: - """Build step summary string.""" - parts = [] - if steps: - parts.append(f"{len(steps)} API call{'s' if len(steps) > 1 else ''}") - if sql_steps: - parts.append(f"{len(sql_steps)} SQL step{'s' if len(sql_steps) > 1 else ''}") - return " + ".join(parts) if parts else "no steps" - - -def search_recipes( - api_id: str, - raw_schema: str, - question: str, - k: int = 3, -) -> tuple[list[dict[str, Any]], str]: - """Search for matching recipes and build context string. - - Args: - api_id: API identifier (e.g., "graphql:url" or "rest:url|base") - raw_schema: Raw schema JSON string - question: User's question - k: Max suggestions to return - - Returns: - (suggestions_list, recipe_context_string) - """ - if not raw_schema: - return [], "" - - schema_hash = sha256_hex(raw_schema) - suggestions = RECIPE_STORE.suggest_recipes( - api_id=api_id, - schema_hash=schema_hash, - question=question, - k=k, - ) - if not suggestions: - return [], "" - - # Enrich with recipe params for display - for s in suggestions: - recipe = RECIPE_STORE.get_recipe(s["recipe_id"]) - if recipe: - s["params"] = recipe.get("params", {}) - - return suggestions, build_recipe_context(suggestions) - - -def build_recipe_context(suggestions: list[dict[str, Any]]) -> str: - """Build recipe context for system prompt.""" - if not suggestions: - return "" - - lines = ["\n", "Available recipe tools (sorted by relevance):"] - - for idx, s in enumerate(suggestions, 1): - recipe = RECIPE_STORE.get_recipe(s["recipe_id"]) - if not recipe: - continue - - params_spec = s.get("params", {}) - param_list = [] - for k, spec in params_spec.items(): - if isinstance(spec, dict): - typ = spec.get("type", "str") - default = spec.get("default") - param_list.append( - f"{k}: {typ} = {default}" if default is not None else f"{k}: {typ}" - ) - else: - param_list.append(f"{k}: str") - - tool_name = s.get("tool_name") or _sanitize_for_tool_name(s["question"]) - score = s["score"] - - lines.append(f"\n{idx}. {tool_name}({', '.join(param_list)})") - lines.append(f' Question: "{s["question"]}"') - lines.append(f" Score: {score:.2f} ({_score_hint(score)})") - lines.append( - f" Steps: {_steps_summary(recipe.get('steps', []), recipe.get('sql_steps', []))}" - ) - - lines.append("") - return "\n".join(lines) - - -def error_json(msg: str) -> str: - """Build JSON error response.""" - return json.dumps({"success": False, "error": msg}, indent=2) - - -def validate_and_prepare_recipe( - recipe_id: str, - params_json: str, - raw_schema_var: ContextVar[str], -) -> tuple[dict[str, Any] | None, dict[str, Any] | None, str]: - """Validate recipe and prepare params. Returns (recipe, params, error_json).""" - try: - raw_schema = raw_schema_var.get() - except LookupError: - raw_schema = "" - if not raw_schema: - return None, None, error_json("schema not loaded") - - recipe = RECIPE_STORE.get_recipe(recipe_id) - if not recipe: - return None, None, error_json(f"recipe not found: {recipe_id}") - - provided: dict[str, Any] = {} - if params_json: - try: - provided = json.loads(params_json) - except json.JSONDecodeError as e: - return None, None, error_json(f"invalid params_json: {e.msg}") - - validated, err = validate_recipe_params(recipe.get("params", {}), provided) - if err: - return None, None, err - return recipe, validated, "" - - -def validate_recipe_params( - params_spec: dict[str, Any], - provided: dict[str, Any], -) -> tuple[dict[str, Any] | None, str]: - """Validate and merge recipe params. Returns (params, error_json).""" - # Reject unknown params - if params_spec: - extra = set(provided.keys()) - set(params_spec.keys()) - if extra: - return None, error_json(f"unexpected params: {', '.join(sorted(extra))}") - - # All declared params are required (defaults are example values, not fallbacks) - for pname, spec in params_spec.items(): - if pname not in provided: - return None, error_json(f"missing required param: {pname}") - - return dict(provided), "" - - -def _sanitize_for_tool_name(question: str) -> str: - """Convert question to valid Python identifier (max 40 chars).""" - name = re.sub(r"[^\w\s]", "", question.lower()) - name = re.sub(r"\s+", "_", name) - name = name[:40].strip("_") - if name and name[0].isdigit(): - name = "r_" + name - return name - - -async def execute_recipe_steps( - recipe: dict[str, Any], - params: dict[str, Any], - query_results_var: ContextVar[dict[str, Any]], - last_result_var: ContextVar[list[Any]], - api_step_executor, - executed_items_list, -) -> tuple[bool, Any, list[str], str]: - """Execute recipe steps (API + SQL). Returns (success, last_data, executed_sql, error_json).""" - results = _get_results_context(query_results_var) - - for step_idx, step in enumerate(recipe.get("steps", [])): - success, data, error, call_rec = await api_step_executor(step_idx, step, params, results) - if not success: - return False, None, [], error - - if call_rec: - executed_items_list.append(call_rec) - - if data is not None: - try: - last_result_var.get()[0] = data - except LookupError: - pass - - success, executed_sql, error = _execute_sql_steps( - recipe.get("sql_steps", []), params, results, last_result_var - ) - if not success: - return False, None, executed_sql, error - - try: - return True, last_result_var.get()[0], executed_sql, "" - except LookupError: - return True, None, executed_sql, "" diff --git a/api_agent/recipe/contracts.py b/api_agent/recipe/contracts.py new file mode 100644 index 0000000..0282f24 --- /dev/null +++ b/api_agent/recipe/contracts.py @@ -0,0 +1,421 @@ +"""Recipe public contract and private execution helpers.""" + +from __future__ import annotations + +import re +from typing import Any + +from .templates import _PLACEHOLDER_RE + +TOOL_ARG_TYPES = {"str", "int", "float", "bool"} +SUPPORTED_TRANSFORMS = {"contains_pattern"} +SUPPORTED_INPUT_MODES = {"single", "map", "batch"} + + +def has_recipe_contract(recipe: dict[str, Any]) -> bool: + return isinstance(recipe.get("public_contract"), dict) and isinstance( + recipe.get("execution_plan"), dict + ) + + +def get_public_contract(recipe: dict[str, Any]) -> dict[str, Any]: + contract = recipe.get("public_contract") + return contract if isinstance(contract, dict) else {} + + +def get_execution_plan(recipe: dict[str, Any]) -> dict[str, Any]: + plan = recipe.get("execution_plan") + return plan if isinstance(plan, dict) else {} + + +def get_validation_fixture(recipe: dict[str, Any]) -> dict[str, Any]: + fixture = recipe.get("validation_fixture") + return fixture if isinstance(fixture, dict) else {} + + +def get_recipe_tool_name(recipe: dict[str, Any]) -> str: + name = get_public_contract(recipe).get("tool_name") + return name if isinstance(name, str) else "" + + +def get_recipe_description(recipe: dict[str, Any]) -> str: + description = get_public_contract(recipe).get("description") + return description if isinstance(description, str) else "" + + +def get_recipe_tool_args(recipe: dict[str, Any]) -> dict[str, Any]: + tool_args = get_public_contract(recipe).get("tool_args") + return tool_args if isinstance(tool_args, dict) else {} + + +def get_recipe_steps(recipe: dict[str, Any]) -> list[Any]: + steps = get_execution_plan(recipe).get("steps") + return steps if isinstance(steps, list) else [] + + +def get_validation_tool_args(recipe: dict[str, Any]) -> dict[str, Any]: + tool_args = get_validation_fixture(recipe).get("tool_args") + return tool_args if isinstance(tool_args, dict) else {} + + +def get_step_id(step: dict[str, Any]) -> str: + step_id = step.get("id") + return step_id if isinstance(step_id, str) else "" + + +def get_step_input(step: dict[str, Any]) -> dict[str, Any]: + step_input = step.get("input") + return step_input if isinstance(step_input, dict) else {} + + +def get_step_call(step: dict[str, Any]) -> dict[str, Any]: + call = step.get("call") + return call if isinstance(call, dict) else {} + + +def get_step_output(step: dict[str, Any]) -> dict[str, Any]: + output = step.get("output") + return output if isinstance(output, dict) else {} + + +def get_step_output_name(step: dict[str, Any]) -> str: + name = get_step_output(step).get("name") + return name if isinstance(name, str) else "" + + +def find_template_vars(steps: list[Any]) -> set[str]: + found: set[str] = set() + for step in steps: + if not isinstance(step, dict): + continue + call = get_step_call(step) + tmpl = ( + step.get("query_template") if step.get("kind") == "sql" else call.get("query_template") + ) + if isinstance(tmpl, str): + found.update(_PLACEHOLDER_RE.findall(tmpl)) + for key in ("path_params", "query_params", "body"): + _find_param_refs(call.get(key), found) + return found + + +def _find_param_refs(obj: Any, found: set[str]) -> None: + if isinstance(obj, dict): + if set(obj.keys()) == {"$var"} and isinstance(obj.get("$var"), str): + found.add(obj["$var"]) + return + for value in obj.values(): + _find_param_refs(value, found) + elif isinstance(obj, list): + for value in obj: + _find_param_refs(value, found) + + +_ARG_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def validate_tool_args(tool_args: dict[str, Any]) -> str: + if not isinstance(tool_args, dict): + return "invalid tool_args" + for name, spec in tool_args.items(): + if not isinstance(name, str) or not _ARG_NAME_RE.match(name): + return "invalid tool arg name" + if not isinstance(spec, dict): + return "invalid tool arg spec" + if spec.get("type") not in TOOL_ARG_TYPES: + return "invalid tool arg type" + description = spec.get("description") + if description is not None and not isinstance(description, str): + return "invalid tool arg description" + return "" + + +def validate_recipe_contract(recipe: dict[str, Any], api_type: str) -> str: + if not has_recipe_contract(recipe): + return "missing public contract or execution plan" + + tool_args = get_recipe_tool_args(recipe) + if err := validate_tool_args(tool_args): + return err + + fixture_args = get_validation_tool_args(recipe) + if set(fixture_args.keys()) != set(tool_args.keys()): + return "validation fixture args mismatch" + + steps = get_recipe_steps(recipe) + if not steps: + return "missing execution steps" + + step_ids: set[str] = set() + available_results: set[str] = set() + for step in steps: + if not isinstance(step, dict): + return "invalid recipe step" + + kind = step.get("kind") + if kind not in {"graphql", "rest", "sql"}: + return "unsupported recipe step" + if api_type == "rest" and kind == "graphql": + return "recipe step api mismatch" + if api_type == "graphql" and kind == "rest": + return "recipe step api mismatch" + + step_id = get_step_id(step) + if not step_id or not _ARG_NAME_RE.match(step_id): + return "invalid step id" + if step_id in step_ids: + return "duplicate step id" + step_ids.add(step_id) + + output_name = get_step_output_name(step) + if not output_name or not _ARG_NAME_RE.match(output_name): + return "invalid step output" + if output_name in available_results: + return "duplicate step output" + + if err := _validate_step_operation(step): + return err + if err := _validate_step_input(step, tool_args, available_results): + return err + + available_results.add(output_name) + + return "" + + +def _validate_step_operation(step: dict[str, Any]) -> str: + kind = step.get("kind") + if kind == "sql": + if not isinstance(step.get("query_template"), str): + return "missing sql query template" + return "" + + call = get_step_call(step) + if kind == "graphql": + if not isinstance(call.get("query_template"), str): + return "missing graphql query template" + return "" + + if kind == "rest": + method = str(call.get("method") or "GET").upper() + if method != "GET": + return "unsafe REST recipe" + if not isinstance(call.get("path"), str): + return "missing rest path" + return "" + + return "unsupported recipe step" + + +def _validate_step_input( + step: dict[str, Any], + tool_args: dict[str, Any], + available_results: set[str], +) -> str: + step_input = get_step_input(step) + if not step_input: + return "missing step input" + + mode = step_input.get("mode") + if mode not in SUPPORTED_INPUT_MODES: + return "unsupported step input mode" + if step.get("kind") == "sql" and mode != "single": + return "sql steps must be single" + + with_vars = step_input.get("with") or {} + if not isinstance(with_vars, dict): + return "invalid step input" + for name, source in with_vars.items(): + if not isinstance(name, str) or not _ARG_NAME_RE.match(name): + return "invalid step input" + if err := _validate_input_source(source, tool_args): + return err + + bind = step_input.get("bind") or {} + if not isinstance(bind, dict): + return "invalid step binding" + for name, field in bind.items(): + if not isinstance(name, str) or not _ARG_NAME_RE.match(name): + return "invalid step binding" + if not isinstance(field, str) or not field: + return "invalid step binding" + + source_name = step_input.get("from") + if mode == "single": + if source_name is not None or bind: + return "single step cannot bind rows" + else: + if not isinstance(source_name, str) or source_name not in available_results: + return "step input must reference prior output" + if not bind: + return "mapped step must bind fields" + + used_vars = find_template_vars([step]) + provided_vars = set(with_vars.keys()) | set(bind.keys()) + if used_vars != provided_vars: + return "template vars and step input mismatch" + + attach_binding = get_step_output(step).get("attach_binding") or [] + if not isinstance(attach_binding, list): + return "invalid output binding" + if attach_binding and mode != "map": + return "invalid output binding" + for name in attach_binding: + if not isinstance(name, str) or name not in bind: + return "invalid output binding" + + return "" + + +def _validate_input_source(source: Any, tool_args: dict[str, Any]) -> str: + if not isinstance(source, dict): + return "invalid value source" + + arg = source.get("value") + if not isinstance(arg, str) or arg not in tool_args: + return "value source references missing arg" + + transform = source.get("transform") + if transform is not None and transform not in SUPPORTED_TRANSFORMS: + return "unsupported value transform" + + allowed_keys = {"value", "transform"} + if set(source.keys()) - allowed_keys: + return "invalid value source" + return "" + + +def resolve_recipe_values( + recipe: dict[str, Any], + public_args: dict[str, Any], +) -> tuple[dict[str, Any] | None, str]: + tool_args = get_recipe_tool_args(recipe) + extra = set(public_args.keys()) - set(tool_args.keys()) + if extra: + return None, f"unexpected params: {', '.join(sorted(extra))}" + missing = set(tool_args.keys()) - set(public_args.keys()) + if missing: + return None, f"missing required param: {sorted(missing)[0]}" + + return dict(public_args), "" + + +def resolve_step_input_values( + step: dict[str, Any], + public_args: dict[str, Any], + results: dict[str, Any], +) -> tuple[list[tuple[dict[str, Any], dict[str, Any]]] | None, str]: + step_input = get_step_input(step) + mode = step_input.get("mode") + if mode not in SUPPORTED_INPUT_MODES: + return None, "unsupported step input mode" + + fixed_values, err = _resolve_with_values(step_input.get("with") or {}, public_args) + if err: + return None, err + assert fixed_values is not None + + if mode == "single": + return [(fixed_values, {})], "" + + source_name = step_input.get("from") + if not isinstance(source_name, str): + return None, "step input must reference prior output" + rows = results.get(source_name) + if not isinstance(rows, list): + return None, f"missing step result: {source_name}" + + bind = step_input.get("bind") or {} + if not isinstance(bind, dict): + return None, "invalid step binding" + + if mode == "batch": + batch_values: dict[str, list[Any]] = {} + for var_name, field in bind.items(): + values, field_error = _collect_field_values(rows, str(field), source_name) + if field_error: + return None, field_error + batch_values[str(var_name)] = values + return [({**fixed_values, **batch_values}, batch_values)] if rows else [], "" + + input_sets: list[tuple[dict[str, Any], dict[str, Any]]] = [] + for row in rows: + bound_values: dict[str, Any] = {} + for var_name, field in bind.items(): + found, value = _get_field(row, str(field)) + if not found or value is None: + return None, f"missing step field values: {source_name}.{field}" + bound_values[str(var_name)] = value + input_sets.append(({**fixed_values, **bound_values}, bound_values)) + return input_sets, "" + + +def _resolve_with_values( + with_vars: dict[str, Any], + public_args: dict[str, Any], +) -> tuple[dict[str, Any] | None, str]: + values: dict[str, Any] = {} + for name, source in with_vars.items(): + if not isinstance(source, dict): + return None, f"invalid value source: {name}" + arg = source.get("value") + if not isinstance(arg, str) or arg not in public_args: + return None, f"invalid value source: {name}" + value = public_args[arg] + transform = source.get("transform") + if transform == "contains_pattern": + value = _contains_pattern(value) + elif transform is not None: + return None, f"invalid value source: {name}" + values[name] = value + return values, "" + + +def _contains_pattern(value: Any) -> str: + parts = [part for part in re.split(r"[^0-9A-Za-z]+", str(value)) if part] + return f"%{'%'.join(parts)}%" if parts else "%" + + +def _collect_field_values( + rows: list[Any], + field: str, + source_name: str, +) -> tuple[list[Any], str]: + values: list[Any] = [] + for row in rows: + found, value = _get_field(row, field) + if not found or value is None: + return [], f"missing step field values: {source_name}.{field}" + values.append(value) + return values, "" + + +def normalize_result(value: Any) -> Any: + if value is None: + return None + if isinstance(value, list): + return [_canonical_value(row) for row in value] + return _canonical_value(value) + + +def results_equivalent(left: Any, right: Any) -> bool: + if left is None or right is None: + return False + return normalize_result(left) == normalize_result(right) + + +def _canonical_value(value: Any) -> Any: + if isinstance(value, dict): + return {str(k): _canonical_value(value[k]) for k in sorted(value)} + if isinstance(value, list): + return [_canonical_value(item) for item in value] + return value + + +def _get_field(row: Any, field: str) -> tuple[bool, Any]: + value = row + for part in field.split("."): + if not isinstance(value, dict) or part not in value: + return False, None + value = value[part] + return True, value diff --git a/api_agent/recipe/execution.py b/api_agent/recipe/execution.py new file mode 100644 index 0000000..46c7315 --- /dev/null +++ b/api_agent/recipe/execution.py @@ -0,0 +1,347 @@ +"""Recipe validation and execution helpers.""" + +from __future__ import annotations + +import json +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any + +from pydantic import ValidationError as PydanticValidationError + +from ..executor import execute_sql, extract_tables_from_response, truncate_for_context +from .contracts import ( + get_recipe_steps, + get_step_call, + get_step_output, + get_step_output_name, + resolve_step_input_values, +) +from .templates import render_param_refs, render_text_template +from .tooling import create_params_model + + +def error_json(msg: Any, *, pretty: bool = True) -> str: + """Build JSON error response.""" + return json.dumps({"success": False, "error": msg}, indent=2 if pretty else None) + + +def validate_recipe_params( + params_spec: dict[str, Any], + provided: dict[str, Any], +) -> tuple[dict[str, Any] | None, str]: + """Validate public recipe tool args.""" + extra = set(provided.keys()) - set(params_spec.keys()) + if extra: + return None, error_json(f"unexpected params: {', '.join(sorted(extra))}") + + for pname in params_spec: + if pname not in provided: + return None, error_json(f"missing required param: {pname}") + + ParamsModel = create_params_model(params_spec, "RecipeParams") + try: + model = ParamsModel.model_validate(provided) + except PydanticValidationError as e: + err = e.errors(include_url=False)[0] if e.errors() else {} + loc = err.get("loc") or ("params",) + pname = str(loc[0]) if loc else "params" + expected = "value" + spec = params_spec.get(pname) + if isinstance(spec, dict): + expected = str(spec.get("type") or expected) + return None, error_json(f"invalid param type: {pname} must be {expected}") + + return model.model_dump(), "" + + +def _get_results_context(query_results_var: ContextVar[dict[str, Any]]) -> dict[str, Any]: + """Get or create results dict from ContextVar.""" + try: + return query_results_var.get() + except LookupError: + results: dict[str, Any] = {} + query_results_var.set(results) + return results + + +def _execute_sql_step( + step: dict[str, Any], + params: dict[str, Any], + results: dict[str, Any], + last_result_var: ContextVar[list[Any]], +) -> tuple[bool, str, str]: + sql_tmpl = step.get("query_template") + if not isinstance(sql_tmpl, str): + return False, "", error_json("invalid sql step") + + input_sets, input_error = build_step_input_sets(step, params, results) + if input_error: + return False, "", error_json(input_error) + if not input_sets: + return False, "", error_json("sql step has no input") + + sql = render_text_template(sql_tmpl, input_sets[0].params) + res = execute_sql(results, sql) + if not res.get("success"): + return False, sql, json.dumps(res, indent=2) + + name = get_step_output_name(step) + if name: + result = res.get("result") + if isinstance(result, list): + results[name] = result + + _set_last_result(last_result_var, res.get("result", [])) + return True, sql, "" + + +def _set_last_result(last_result_var: ContextVar[list[Any]], data: Any) -> None: + try: + last_result_var.get()[0] = data + except LookupError: + pass + + +@dataclass(frozen=True) +class StepInputSet: + params: dict[str, Any] + binding: dict[str, Any] + + +@dataclass(frozen=True) +class RenderedRestCall: + path_params: dict[str, Any] | None + query_params: dict[str, Any] | None + body: Any + binding: dict[str, Any] + + +@dataclass(frozen=True) +class RenderedGraphQLQuery: + query: str + binding: dict[str, Any] + + +def build_step_input_sets( + step: dict[str, Any], + params: dict[str, Any], + results: dict[str, Any], +) -> tuple[list[StepInputSet], str]: + """Resolve one step's scalar and row bindings.""" + raw_sets, error = resolve_step_input_values(step, params, results) + if error: + return [], error + assert raw_sets is not None + return [StepInputSet(values, binding) for values, binding in raw_sets], "" + + +def get_step_result_name(step: dict[str, Any]) -> str: + return get_step_output_name(step) or "data" + + +def render_graphql_query_sets( + step: dict[str, Any], + params: dict[str, Any], + results: dict[str, Any], +) -> tuple[list[RenderedGraphQLQuery], str]: + tmpl = get_step_call(step).get("query_template") + if not isinstance(tmpl, str): + return [], "missing query_template" + + input_sets, input_error = build_step_input_sets(step, params, results) + if input_error: + return [], input_error + + rendered: list[RenderedGraphQLQuery] = [] + for input_set in input_sets: + try: + query = render_text_template(tmpl, input_set.params) + except KeyError as e: + return [], str(e) + rendered.append(RenderedGraphQLQuery(query=query, binding=input_set.binding)) + return rendered, "" + + +def render_rest_call_sets( + step: dict[str, Any], + params: dict[str, Any], + results: dict[str, Any], +) -> tuple[list[RenderedRestCall], str]: + """Render REST call params from explicit step bindings.""" + input_sets, input_error = build_step_input_sets(step, params, results) + if input_error: + return [], input_error + + rendered: list[RenderedRestCall] = [] + call = get_step_call(step) + for input_set in input_sets: + try: + pp = render_param_refs(call.get("path_params") or {}, input_set.params) + qp = render_param_refs(call.get("query_params") or {}, input_set.params) + bd = render_param_refs(call.get("body") or {}, input_set.params) + except KeyError as e: + return [], str(e) + + rendered.append( + RenderedRestCall( + path_params=pp if isinstance(pp, dict) else None, + query_params=qp if isinstance(qp, dict) else None, + body=bd if bd else None, + binding=input_set.binding, + ) + ) + return rendered, "" + + +def get_rest_step_call(step: dict[str, Any]) -> tuple[str, str, str]: + call = get_step_call(step) + method = str(call.get("method", "GET")).upper() + path = str(call.get("path", "")) + return method, path, get_step_result_name(step) + + +def build_rest_call_record( + *, + method: str, + path: str, + name: str, + rendered: RenderedRestCall, +) -> dict[str, Any]: + return { + "method": method, + "path": path, + "path_params": json.dumps(rendered.path_params) if rendered.path_params else "", + "query_params": json.dumps(rendered.query_params) if rendered.query_params else "", + "body": json.dumps(rendered.body) if rendered.body else "", + "name": name, + "success": True, + } + + +def attach_binding_values( + rows: list[Any], + binding: dict[str, Any], + output: dict[str, Any], +) -> list[Any]: + """Attach map keys needed by downstream SQL joins.""" + attach = output.get("attach_binding") or [] + if not attach or not binding: + return rows + + attached: list[Any] = [] + for row in rows: + if not isinstance(row, dict): + attached.append(row) + continue + next_row = dict(row) + for name in attach: + if isinstance(name, str) and name in binding: + next_row[name] = binding[name] + attached.append(next_row) + return attached + + +def collect_step_rows( + data: Any, + step: dict[str, Any], + binding: dict[str, Any], +) -> list[Any]: + name = get_step_result_name(step) + tables, _ = extract_tables_from_response(data, name) + rows = tables.get(name) + if not isinstance(rows, list): + return [] + return attach_binding_values(rows, binding, get_step_output(step)) + + +def store_step_rows( + results: dict[str, Any], + step: dict[str, Any], + rows: list[Any], +) -> str: + name = get_step_result_name(step) + results[name] = rows + return name + + +def format_recipe_response( + last_result_var: ContextVar[list[Any]], + executed_items: list[Any], + executed_sql: list[str], + item_key: str, +) -> str: + """Format recipe JSON response with truncation.""" + try: + last_rows = last_result_var.get()[0] + except LookupError: + last_rows = None + + base = {"success": True, item_key: executed_items, "executed_sql": executed_sql} + if isinstance(last_rows, list): + base.update(truncate_for_context(last_rows, "sql_result")) + return json.dumps(base, indent=2) + + +def build_partial_result( + last_data: Any, + api_calls: list[Any], + turn_info: str, + call_key: str, +) -> dict[str, Any]: + """Build partial result dict for MaxTurnsExceeded.""" + if last_data: + return { + "ok": True, + "data": f"[Partial - {turn_info}] Max turns exceeded but data retrieved.", + "result": last_data, + call_key: api_calls, + "error": None, + } + return { + "ok": False, + "data": None, + "result": None, + call_key: api_calls, + "error": f"Max turns exceeded ({turn_info}), no data retrieved", + } + + +async def execute_recipe_steps( + recipe: dict[str, Any], + params: dict[str, Any], + query_results_var: ContextVar[dict[str, Any]], + last_result_var: ContextVar[list[Any]], + api_step_executor, + executed_items_list, +) -> tuple[bool, Any, list[str], str]: + """Execute recipe steps (API + SQL). Returns (success, last_data, executed_sql, error_json).""" + results = _get_results_context(query_results_var) + executed_sql: list[str] = [] + + for step_idx, step in enumerate(get_recipe_steps(recipe)): + if isinstance(step, dict) and step.get("kind") == "sql": + success, sql, error = _execute_sql_step(step, params, results, last_result_var) + if sql: + executed_sql.append(sql) + if not success: + return False, None, executed_sql, error + continue + + success, data, error, call_rec = await api_step_executor(step_idx, step, params, results) + if not success: + return False, None, executed_sql, error + + if call_rec: + if isinstance(call_rec, list): + executed_items_list.extend(call_rec) + else: + executed_items_list.append(call_rec) + + if data is not None: + _set_last_result(last_result_var, data) + + try: + return True, last_result_var.get()[0], executed_sql, "" + except LookupError: + return True, None, executed_sql, "" diff --git a/api_agent/recipe/extractor.py b/api_agent/recipe/extractor.py index af51259..f4debd2 100644 --- a/api_agent/recipe/extractor.py +++ b/api_agent/recipe/extractor.py @@ -3,197 +3,52 @@ from __future__ import annotations import json +import logging import re from typing import Any -from agents import Agent, Runner - -from .store import get_example_values, normalize_ws, render_param_refs, render_text_template - -_EXTRACTOR_INSTRUCTIONS = """You are a recipe extractor. Convert executed API calls into reusable templates. - -INPUT: -- api_type: "graphql" or "rest" -- question: user's question -- steps: executed API calls (preserve order) -- sql_steps: executed SQL queries (preserve order) -- existing_recipes: list of existing recipes for this API/schema (tool_name, question, steps, sql_steps, params) - -OUTPUT: Single JSON object (no markdown): -{ - "tool_name": "", - "params": {"paramName": {"type": "str|int|float|bool", "default": }}, - "steps": [], - "sql_steps": [] -} - -TOOL_NAME REQUIREMENTS: -- snake_case Python identifier (lowercase, underscores only) -- Max 40 characters -- Start with a verb (get, list, fetch, find, search, etc.) -- Descriptive but concise (e.g., "get_recent_users" not "get_all_users_who_registered_recently") -- No special characters, only letters, numbers, underscores -- If an existing recipe already matches this execution, reuse its tool_name. -- Otherwise choose a tool_name not in existing_recipes. - -STEP FORMATS: -- GraphQL: {"kind": "graphql", "name": "...", "query_template": "...{{param}}..."} -- REST: {"kind": "rest", "name": "...", "method": "GET", "path": "/x", "path_params": {}, "query_params": {}, "body": {}} - Use {"$param": "paramName"} for parameterized values in REST objects. -- SQL: Use {{param}} for parameterized values in sql_steps strings. - Example: "WHERE name ILIKE '{{startsWith}}%'" with param startsWith default "A" - -PARAMETERIZE these (user-specific values): -- IDs, limits, offsets, search terms, filters, dates, LIKE/ILIKE patterns - -DO NOT parameterize: -- API paths, HTTP methods, field names, table names, static config - -RULES: -- Keep SAME number of steps in SAME order -- Default values MUST match the original execution (so template renders back to original) -- Output valid JSON only -""" - - -def _parse_json_maybe(text: str) -> dict[str, Any] | None: - """Parse JSON dict, extracting from surrounding text if needed.""" - if not text: - return None - - # Try direct parse - try: - val = json.loads(text) - if isinstance(val, dict): - return val - except json.JSONDecodeError: - pass - - # Try extracting JSON from text - start, end = text.find("{"), text.rfind("}") - if start != -1 and end > start: - try: - val = json.loads(text[start : end + 1]) - if isinstance(val, dict): - return val - except json.JSONDecodeError: - pass - - return None - - -def _get_params_defaults(params_spec: dict[str, Any] | None) -> dict[str, Any]: - return get_example_values(params_spec or {}, {}) - +from agents import Agent, AgentOutputSchema, Runner +from agents.exceptions import ModelBehaviorError -def _canon_obj(v: Any) -> Any: - """Normalize None to empty dict for comparisons.""" - return {} if v is None else v +from .contracts import ( + get_recipe_description, + get_recipe_steps, + get_recipe_tool_args, + get_recipe_tool_name, + get_validation_tool_args, + validate_recipe_contract, +) +from .extractor_models import ExtractedRecipeOutput +from .extractor_prompts import build_extractor_instructions +logger = logging.getLogger(__name__) -_PLACEHOLDER_RE = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}") +def _structured_recipe(output: Any) -> dict[str, Any] | None: + if isinstance(output, dict): + try: + output = ExtractedRecipeOutput.model_validate(output) + except ValueError: + _reject_recipe("invalid structured extractor output") + return None -def _find_used_params(recipe: dict[str, Any], api_type: str) -> set[str]: - """Find all {{param}} and $param references in recipe templates.""" - used: set[str] = set() - - # Check sql_steps for {{param}} - for sql in recipe.get("sql_steps", []): - if isinstance(sql, str): - used.update(_PLACEHOLDER_RE.findall(sql)) - - # Check steps - for step in recipe.get("steps", []): - if not isinstance(step, dict): - continue - # GraphQL: check query_template - if api_type == "graphql": - tmpl = step.get("query_template", "") - if isinstance(tmpl, str): - used.update(_PLACEHOLDER_RE.findall(tmpl)) - # REST: check $param refs in path_params, query_params, body - else: - for key in ("path_params", "query_params", "body"): - _find_param_refs(step.get(key), used) - - return used - - -def _find_param_refs(obj: Any, found: set[str]) -> None: - """Recursively find {'$param': 'name'} refs.""" - if isinstance(obj, dict): - if set(obj.keys()) == {"$param"} and isinstance(obj.get("$param"), str): - found.add(obj["$param"]) - else: - for v in obj.values(): - _find_param_refs(v, found) - elif isinstance(obj, list): - for v in obj: - _find_param_refs(v, found) - - -def _validate_step_graphql(orig: dict, recipe_step: dict, params: dict) -> bool: - """Validate GraphQL step renders to original.""" - if recipe_step.get("name") != orig.get("name"): - return False - tmpl = recipe_step.get("query_template") - if not isinstance(tmpl, str): - return False - return normalize_ws(render_text_template(tmpl, params)) == normalize_ws( - str(orig.get("query", "")) - ) + if not isinstance(output, ExtractedRecipeOutput): + _reject_recipe("invalid extractor output type") + return None + return output.model_dump(exclude_none=True, by_alias=True) -def _validate_step_rest(orig: dict, recipe_step: dict, params: dict) -> bool: - """Validate REST step renders to original.""" - if recipe_step.get("name") != orig.get("name"): - return False - if str(recipe_step.get("method", "")).upper() != str(orig.get("method", "")).upper(): - return False - if recipe_step.get("path") != orig.get("path"): - return False - for key in ("path_params", "query_params", "body"): - rendered = render_param_refs(_canon_obj(recipe_step.get(key)), params) - if rendered != _canon_obj(orig.get(key)): - return False - return True +def _reject_recipe(reason: str) -> None: + logger.info("Skipping recipe extraction: %s", reason) -def _validate_equivalence( - *, - api_type: str, - original_steps: list[dict[str, Any]], - original_sql: list[str], - recipe: dict[str, Any], -) -> bool: - """Validate recipe renders back to original execution.""" - params_spec = recipe.get("params") - params = _get_params_defaults(params_spec if isinstance(params_spec, dict) else {}) - - r_steps = recipe.get("steps") - r_sql = recipe.get("sql_steps") - if not isinstance(r_steps, list) or not isinstance(r_sql, list): - return False - if len(r_steps) != len(original_steps) or len(r_sql) != len(original_sql): - return False - - for orig, rec in zip(original_steps, r_steps): - if not isinstance(rec, dict) or rec.get("kind") != orig.get("kind"): - return False - - validator = _validate_step_graphql if api_type == "graphql" else _validate_step_rest - if not validator(orig, rec, params): - return False - - for o_sql, r_tmpl in zip(original_sql, r_sql): - if not isinstance(r_tmpl, str): - return False - if normalize_ws(render_text_template(r_tmpl, params)) != normalize_ws(o_sql): - return False - - return True +_RESERVED_TOOL_PREFIXES = ("r_", "api_", "rest_", "graphql_") +_MAX_EXTRACTOR_TURNS = 2 +_LOWER_EQUALS_VAR_RE = re.compile( + r"lower\((?P[^)]+)\)\s*=\s*(?:lower\()?['\"]\{\{(?P[A-Za-z_][A-Za-z0-9_]*)\}\}['\"]\)?", + re.IGNORECASE, +) async def extract_recipe( @@ -201,8 +56,9 @@ async def extract_recipe( api_type: str, question: str, steps: list[dict[str, Any]], - sql_steps: list[str], + result: Any | None = None, existing_recipes: list[dict[str, Any]] | None = None, + validation_feedback: dict[str, Any] | None = None, ) -> dict[str, Any] | None: """Extract parameterized recipe from execution trace. Returns recipe or None.""" from ..agent.model import get_run_config, model @@ -210,65 +66,117 @@ async def extract_recipe( agent = Agent( name="recipe-extractor", model=model, - instructions=_EXTRACTOR_INSTRUCTIONS, + instructions=build_extractor_instructions(api_type), tools=[], + output_type=AgentOutputSchema(ExtractedRecipeOutput, strict_json_schema=False), ) payload = { "api_type": api_type, "question": question, "steps": steps, - "sql_steps": sql_steps, + "result": result, "existing_recipes": existing_recipes or [], } + if validation_feedback: + payload["validation_feedback"] = validation_feedback - result = await Runner.run( - agent, - json.dumps(payload, indent=2), - max_turns=6, - run_config=get_run_config(), - ) - if not result.final_output: + try: + run_result = await Runner.run( + agent, + json.dumps(payload, indent=2), + max_turns=_MAX_EXTRACTOR_TURNS, + run_config=get_run_config(), + ) + except ModelBehaviorError: + _reject_recipe("invalid extractor output") return None - - recipe = _parse_json_maybe(str(result.final_output)) - if not recipe: + if not run_result.final_output: + _reject_recipe("empty extractor output") return None - # Basic structure check - if "steps" not in recipe or "sql_steps" not in recipe: + recipe = _structured_recipe(run_result.final_output) + if not recipe: return None - if not isinstance(recipe.get("params"), dict): - recipe["params"] = {} - # Validate tool_name: must be valid Python identifier, max 40 chars - tool_name = recipe.get("tool_name", "") - if not isinstance(tool_name, str) or not tool_name: + tool_name = get_recipe_tool_name(recipe) + if not tool_name: + _reject_recipe("missing tool_name") return None if not re.match(r"^[a-z][a-z0-9_]{0,39}$", tool_name): + _reject_recipe("invalid tool_name") + return None + if tool_name.startswith(_RESERVED_TOOL_PREFIXES): + _reject_recipe("invalid tool_name prefix") return None - # Validate declared params are actually used in templates - declared_params = set(recipe.get("params", {}).keys()) - used_params = _find_used_params(recipe, api_type) - if declared_params and not used_params: - # Params declared but none used - LLM didn't parameterize templates + description = " ".join(get_recipe_description(recipe).split()) + if len(description) < 40 or len(description) > 1000: + _reject_recipe("invalid description length") return None - if declared_params != used_params: - # Mismatch - prune unused params, reject if used params undeclared - undeclared = used_params - declared_params - if undeclared: - return None # Template refs param not in params spec - # Remove unused declared params - recipe["params"] = {k: v for k, v in recipe["params"].items() if k in used_params} - - # Core validation: render(template, defaults) == original - if not _validate_equivalence( - api_type=api_type, - original_steps=steps, - original_sql=sql_steps, - recipe=recipe, - ): + if "recipe name" in description.lower(): + _reject_recipe("invalid description text") + return None + recipe["public_contract"]["description"] = description + _repair_alias_sql_filters(recipe) + + if err := validate_recipe_contract(recipe, api_type): + _reject_recipe(err) return None return recipe + + +def _repair_alias_sql_filters(recipe: dict[str, Any]) -> None: + tool_args = get_recipe_tool_args(recipe) + fixture_args = get_validation_tool_args(recipe) + + for step in get_recipe_steps(recipe): + if not isinstance(step, dict) or step.get("kind") != "sql": + continue + query_template = step.get("query_template") + if not isinstance(query_template, str): + continue + step_input = step.get("input") + with_vars = step_input.get("with") if isinstance(step_input, dict) else None + if not isinstance(with_vars, dict): + continue + + step["query_template"] = _LOWER_EQUALS_VAR_RE.sub( + lambda match: _repaired_sql_match(match, with_vars, tool_args, fixture_args), + query_template, + ) + + +def _repaired_sql_match( + match: re.Match[str], + with_vars: dict[str, Any], + tool_args: dict[str, Any], + fixture_args: dict[str, Any], +) -> str: + var_name = match.group("var") + source = with_vars.get(var_name) + if not isinstance(source, dict): + return match.group(0) + + arg_name = source.get("value") + arg_spec = tool_args.get(arg_name) + if not isinstance(arg_name, str) or not isinstance(arg_spec, dict): + return match.group(0) + if arg_spec.get("type") != "str": + return match.group(0) + + transform = source.get("transform") + if transform is None and not _looks_like_alias(fixture_args.get(arg_name)): + return match.group(0) + if transform not in (None, "contains_pattern"): + return match.group(0) + + source["transform"] = "contains_pattern" + return f"lower({match.group('field')}) LIKE lower('{{{{{var_name}}}}}')" + + +def _looks_like_alias(value: Any) -> bool: + return isinstance(value, str) and any( + not char.isalnum() and not char.isspace() for char in value + ) diff --git a/api_agent/recipe/extractor_models.py b/api_agent/recipe/extractor_models.py new file mode 100644 index 0000000..c616eca --- /dev/null +++ b/api_agent/recipe/extractor_models.py @@ -0,0 +1,92 @@ +"""Structured recipe extractor output models.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class RecipeToolArgOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: str + description: str | None = None + + +class RecipeInputValueOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + value: str + transform: str | None = None + + +class RecipeStepInputOutput(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + mode: str + from_: str | None = Field(default=None, alias="from") + bind: dict[str, str] | None = None + with_: dict[str, RecipeInputValueOutput] = Field(default_factory=dict, alias="with") + + +class RecipeCallOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + query_template: str | None = None + method: str | None = None + path: str | None = None + path_params: dict[str, Any] | None = None + query_params: dict[str, Any] | None = None + body: dict[str, Any] | list[Any] | str | int | float | bool | None = None + + +class RecipeStepOutputSpec(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + attach_binding: list[str] | None = None + + +class RecipeStepOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + kind: str + input: RecipeStepInputOutput + output: RecipeStepOutputSpec + query_template: str | None = None + call: RecipeCallOutput | None = None + + +class RecipePublicContractOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + tool_name: str + description: str + tool_args: dict[str, RecipeToolArgOutput] + + +class RecipeExecutionPlanOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + steps: list[RecipeStepOutput] + + +class RecipeValidationFixtureOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + tool_args: dict[str, Any] = Field(default_factory=dict) + result: Any | None = None + + +class ExtractedRecipeOutput(BaseModel): + """Extractor output is only a candidate recipe, never a save decision.""" + + model_config = ConfigDict(extra="forbid") + + public_contract: RecipePublicContractOutput + execution_plan: RecipeExecutionPlanOutput + validation_fixture: RecipeValidationFixtureOutput = Field( + default_factory=RecipeValidationFixtureOutput + ) diff --git a/api_agent/recipe/extractor_prompts.py b/api_agent/recipe/extractor_prompts.py new file mode 100644 index 0000000..2c8ee5c --- /dev/null +++ b/api_agent/recipe/extractor_prompts.py @@ -0,0 +1,183 @@ +"""Recipe extractor prompt templates.""" + +from __future__ import annotations + +SHARED_EXTRACTOR_INSTRUCTIONS = """You are a recipe tool compiler. Convert a successful API run into an MCP tool contract plus an optimized private execution plan. + +INPUT: +- api_type: "graphql" or "rest" +- question: user's question +- steps: executed workflow steps from the original run (API calls and SQL queries) +- successful steps may include result rows captured from that step +- result: final user-facing tabular result from the original run +- existing_recipes: existing contract recipes for this API/schema +- validation_feedback: optional feedback from a failed deterministic replay of your prior candidate + +OUTPUT: Structured recipe candidate. Deterministic validation decides whether it is saved. +{ + "public_contract": { + "tool_name": "", + "description": "", + "tool_args": {"argName": {"type": "str|int|float|bool", "description": ""}} + }, + "execution_plan": { + "steps": [] + }, + "validation_fixture": { + "tool_args": {"argName": } + } +} + +TOOL_NAME REQUIREMENTS: +- snake_case Python identifier (lowercase, underscores only) +- Max 40 characters +- Use action_resource shape, such as list_users or get_order_total +- Start with a verb (get, list, fetch, find, search, etc.) +- Descriptive but concise (e.g., "get_recent_users" not "get_all_users_who_registered_recently") +- No service/API prefixes, recipe prefixes, special characters, or hyphens +- If an existing recipe already matches this execution, reuse its tool_name. +- Otherwise choose a tool_name not in existing_recipes. + +PUBLIC CONTRACT REQUIREMENTS: +- Tool args are values a client naturally knows from the user's intent. +- Do not expose intermediate execution details as required user args. +- Every tool arg must have type and description. +- validation_fixture.tool_args must include exactly the original values for every public tool arg. + +DESCRIPTION REQUIREMENTS: +- 2-3 short sentences. +- Say exactly when to use this cached workflow. +- Say what result shape it returns. +- Mention required tool args by name when any exist. +- Say not to use it for different fields, joins, or workflow. +- Do not mention API type, implementation counts, step counts, recipe names, or generic "run saved workflow" text. +- Example: "Use for looking up team members by team name. Returns member names and available alternate names as CSV. Requires team. Do not use for unrelated team metadata or different joins." + +STEP FORMATS: +- Every step has id, kind, input, and output.name. +- SQL: {"id": "...", "kind": "sql", "input": {...}, "query_template": "...{{param}}...", "output": {"name": "..."}} + Example: "WHERE name ILIKE '{{startsWith}}%'" with param startsWith default "A" + +INPUT REQUIREMENTS: +- Every {{param}} or {"$var": "name"} in a step must be provided by that step's input.with or input.bind. +- input.mode="single" for calls that only need public tool args. +- input.with maps step vars to public args: {"varName": {"value": "toolArg"}}. +- To transform a search term into a SQL LIKE pattern, use {"varName": {"value": "team", "transform": "contains_pattern"}}. +- contains_pattern splits punctuation-separated aliases into word wildcards: "alpha-suite" renders as "%alpha%suite%". +- If a public value is an alias or punctuation-separated search term, use LIKE with contains_pattern. If it is already the canonical API value, exact equality is valid. +- input.mode="map" for row-by-row API calls from one prior rowset. It requires "from" and "bind". +- input.bind maps step vars to fields in the row from input.from: {"objective_id": "id"}. +- input.mode="batch" only when the API accepts list-valued params in one request/query. +- For multiple dependencies, first add a SQL step that joins them into one ordered binding rowset, then map or batch from that rowset. +- Mapped outputs should use output.attach_binding for keys needed by downstream SQL joins. +- Use SQL ORDER BY before map/batch when final order matters. + +DO NOT parameterize: +- API paths, HTTP methods, field names, table names, static config + +RULES: +- You may optimize the original workflow and use fewer or different steps. +- If validation_feedback is present, repair the candidate so replay can match expected_result. +- Do not hard-code expected_result as a static output; fix the API/SQL dataflow. +- Put SQL in steps as kind="sql". +- Recipes are a dataflow graph: no hidden dependencies, no implicit Cartesian products. +- Output the best candidate that can run from public tool args only. +- It will be rejected unless code can execute it with validation_fixture.tool_args and match the original result. +""" + + +GRAPHQL_RECIPE_RULES = """GRAPHQL RECIPE RULES: +- Only output GraphQL API steps for api_type="graphql". +- GraphQL step: {"id": "...", "kind": "graphql", "input": {...}, "call": {"query_template": "...{{param}}..."}, "output": {"name": "..."}} +- Use {{var}} placeholders inside call.query_template. Every placeholder must be provided by that same step's input.with or input.bind. +- Keep GraphQL field names, operation names, aliases, and static literals fixed. Do not expose them as tool args. +- input.mode="map" is valid for row-by-row detail GraphQL queries from one prior rowset. +- input.mode="batch" is only valid when the GraphQL field accepts a list argument and the rendered query is valid GraphQL syntax. +- When batching string/ID lists, make sure the query_template renders GraphQL list literals correctly. + +GRAPHQL SINGLE EXAMPLE: +{ + "id": "orders", + "kind": "graphql", + "input": {"mode": "single", "with": {"userId": {"value": "userId"}}}, + "call": {"query_template": "{ orders(userId: {{userId}}) { id amount } }"}, + "output": {"name": "orders"} +} + +GRAPHQL MAP EXAMPLE: +[ + { + "id": "filtered_users", + "kind": "sql", + "input": {"mode": "single", "with": {"team": {"value": "team", "transform": "contains_pattern"}}}, + "query_template": "SELECT id FROM users WHERE team_name ILIKE '{{team}}' ORDER BY id", + "output": {"name": "filtered_users"} + }, + { + "id": "user_orders", + "kind": "graphql", + "input": { + "mode": "map", + "from": "filtered_users", + "bind": {"userId": "id"} + }, + "call": {"query_template": "{ orders(userId: {{userId}}) { id amount } }"}, + "output": {"name": "user_orders", "attach_binding": ["userId"]} + } +] +""" + + +REST_RECIPE_RULES = """REST RECIPE RULES: +- Only output REST API steps for api_type="rest". +- REST step: {"id": "...", "kind": "rest", "input": {...}, "call": {"method": "GET", "path": "/x", "path_params": {}, "query_params": {}, "body": {}}, "output": {"name": "..."}} +- REST recipes must be GET-only. +- Use {"$var": "paramName"} for parameterized values in call.path_params, call.query_params, and call.body. +- Keep paths, HTTP methods, static query keys, and static body keys fixed. Do not expose them as tool args. +- input.mode="map" is valid for row-by-row detail REST calls from one prior rowset. +- input.mode="batch" is only valid when the REST endpoint accepts list-valued params in one request. + +REST SINGLE EXAMPLE: +{ + "id": "objectives", + "kind": "rest", + "input": {"mode": "single", "with": {"cycle": {"value": "cycle"}}}, + "call": { + "method": "GET", + "path": "/objectives", + "query_params": {"cycle": {"$var": "cycle"}} + }, + "output": {"name": "objectives"} +} + +REST MAP EXAMPLE: +[ + { + "id": "filtered_objectives", + "kind": "sql", + "input": {"mode": "single", "with": {"team_id": {"value": "team_id"}}}, + "query_template": "SELECT id FROM objectives WHERE assignee.team.id = {{team_id}} ORDER BY id", + "output": {"name": "filtered_objectives"} + }, + { + "id": "key_results", + "kind": "rest", + "input": { + "mode": "map", + "from": "filtered_objectives", + "bind": {"objective_id": "id"} + }, + "call": { + "method": "GET", + "path": "/objectives/{objective_id}/key-results", + "path_params": {"objective_id": {"$var": "objective_id"}} + }, + "output": {"name": "key_results", "attach_binding": ["objective_id"]} + } +] +""" + + +def build_extractor_instructions(api_type: str) -> str: + api_rules = GRAPHQL_RECIPE_RULES if api_type == "graphql" else REST_RECIPE_RULES + return f"{SHARED_EXTRACTOR_INSTRUCTIONS}\n\n{api_rules}" diff --git a/api_agent/recipe/identity.py b/api_agent/recipe/identity.py new file mode 100644 index 0000000..4f854f6 --- /dev/null +++ b/api_agent/recipe/identity.py @@ -0,0 +1,70 @@ +"""Recipe identity and equivalence helpers.""" + +from __future__ import annotations + +import hashlib +import json +import re +from typing import Any + +from .contracts import get_recipe_tool_args + + +def sha256_hex(text: str) -> str: + """Hash text, normalizing JSON when possible for stable schema hashes.""" + normalized = text + try: + parsed = json.loads(text) + except Exception: + parsed = None + if parsed is not None: + normalized = json.dumps(parsed, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def normalize_ws(text: str) -> str: + """Whitespace-normalize for template equivalence checks.""" + return re.sub(r"\s+", " ", (text or "")).strip() + + +def _canonical_value(value: Any) -> Any: + if isinstance(value, dict): + return {str(k): _canonical_value(value[k]) for k in sorted(value)} + if isinstance(value, list): + return [_canonical_value(v) for v in value] + if isinstance(value, str): + return normalize_ws(value) + return value + + +def _stable_tool_args(recipe: dict[str, Any]) -> dict[str, Any]: + stable: dict[str, Any] = {} + for name, spec in get_recipe_tool_args(recipe).items(): + if not isinstance(name, str) or not isinstance(spec, dict): + continue + stable[name] = {"type": spec.get("type", "str")} + return stable + + +def recipe_behavior_payload(recipe: dict[str, Any]) -> dict[str, Any]: + """Stable recipe behavior identity, excluding generated copy/names.""" + return { + "tool_args": _canonical_value(_stable_tool_args(recipe)), + "execution_plan": _canonical_value(recipe.get("execution_plan", {})), + } + + +def recipe_fingerprint( + *, + api_id: str, + schema_hash: str, + recipe: dict[str, Any], +) -> str: + """Canonical recipe fingerprint for idempotent saves.""" + payload = { + "api_type": api_id.split(":", 1)[0], + "api_id": api_id, + "schema_hash": schema_hash, + "behavior": recipe_behavior_payload(recipe), + } + return sha256_hex(json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)) diff --git a/api_agent/recipe/learning.py b/api_agent/recipe/learning.py new file mode 100644 index 0000000..5ce3355 --- /dev/null +++ b/api_agent/recipe/learning.py @@ -0,0 +1,546 @@ +"""Recipe extraction and persistence.""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from ..config import settings +from ..store import ASYNC_API_AGENT_STORE, sha256_hex +from ..tracing import trace_span +from .contracts import ( + get_recipe_tool_args, + get_recipe_tool_name, + get_validation_tool_args, + has_recipe_contract, + results_equivalent, +) +from .execution import error_json, validate_recipe_params +from .extractor import extract_recipe +from .identity import recipe_behavior_payload +from .state import mark_recipe_changed +from .tooling import deduplicate_tool_name + +logger = logging.getLogger(__name__) + + +_MAX_RECIPE_REPAIR_ATTEMPTS = 2 +_VALIDATION_SAMPLE_ROWS = 5 +_SKIP_REASON_MESSAGES = { + "disabled": "disabled", + "skip_condition": "skip condition", + "strong_recipe_match": "strong recipe match", + "missing_steps": "missing steps", + "missing_schema": "missing schema", + "sampled_out": "sampled out", + "extractor_rejected": "extractor rejected", + "missing_validation_runner": "missing validation runner", + "candidate_result_mismatch": "candidate result mismatch", +} + + +def _recipes_equivalent(existing: dict[str, Any], candidate: dict[str, Any]) -> bool: + return recipe_behavior_payload(existing) == recipe_behavior_payload(candidate) + + +async def maybe_extract_and_save_recipe( + api_type: str, + api_id: str, + question: str, + steps: list, + raw_schema: str, + skip_condition: bool = False, + learn_rate: float | None = None, + strong_recipe_match: bool = False, + original_result: Any | None = None, + validate_candidate: Callable[[dict[str, Any], dict[str, Any]], Awaitable[Any]] | None = None, +) -> None: + """Extract and save recipe if conditions met.""" + span_attrs = { + "recipe.api_type": api_type, + "recipe.api_id": api_id[:100], + "recipe.step_count": len(steps), + "recipe.has_schema": bool(raw_schema), + } + with trace_span("recipe.learning", span_attrs) as span: + await _maybe_extract_and_save_recipe( + api_type=api_type, + api_id=api_id, + question=question, + steps=steps, + raw_schema=raw_schema, + skip_condition=skip_condition, + learn_rate=learn_rate, + strong_recipe_match=strong_recipe_match, + original_result=original_result, + validate_candidate=validate_candidate, + span=span, + ) + + +async def _maybe_extract_and_save_recipe( + *, + api_type: str, + api_id: str, + question: str, + steps: list, + raw_schema: str, + skip_condition: bool, + learn_rate: float | None, + strong_recipe_match: bool, + original_result: Any | None, + validate_candidate: Callable[[dict[str, Any], dict[str, Any]], Awaitable[Any]] | None, + span: Any, +) -> None: + if not settings.ENABLE_RECIPES: + _skip_recipe_learning(span, "disabled") + return + if skip_condition: + _skip_recipe_learning(span, "skip_condition") + return + effective_learn_rate = settings.RECIPE_LEARN_RATE if learn_rate is None else learn_rate + _set_recipe_span_attributes(span, {"recipe.learn_rate": effective_learn_rate}) + if strong_recipe_match and effective_learn_rate < 1: + _skip_recipe_learning(span, "strong_recipe_match") + return + if not steps: + _skip_recipe_learning(span, "missing_steps") + return + if not raw_schema: + _skip_recipe_learning(span, "missing_schema") + return + + schema_hash = "" + try: + schema_hash = sha256_hex(raw_schema) + _set_recipe_span_attributes(span, {"recipe.schema_hash": schema_hash[:12]}) + if not should_learn_recipe( + api_id=api_id, + schema_hash=schema_hash, + question=question, + learn_rate=effective_learn_rate, + ): + _skip_recipe_learning(span, "sampled_out") + return + + existing_recipes = [ + recipe + for recipe in await ASYNC_API_AGENT_STORE.list_recipes( + api_id=api_id, schema_hash=schema_hash + ) + if has_recipe_contract(recipe) + ] + extractor_existing_recipes = _extractor_existing_recipes_context(existing_recipes) + _set_recipe_span_attributes(span, {"recipe.existing_count": len(existing_recipes)}) + learning_steps = _recipe_steps_for_learning(steps, span) + validation_result = _validation_result_from_steps(learning_steps, original_result) + extractor_steps = _extractor_steps_context(learning_steps) + extractor_result = _extractor_result_context(validation_result) + recipe: dict[str, Any] | None = None + candidate_result: Any | None = None + validation_feedback: dict[str, Any] | None = None + for validation_attempt in range(_MAX_RECIPE_REPAIR_ATTEMPTS + 1): + _set_recipe_span_attributes( + span, + {"recipe.validation_attempt": validation_attempt + 1}, + ) + recipe = await extract_recipe( + api_type=api_type, + question=question, + steps=extractor_steps, + result=extractor_result, + existing_recipes=extractor_existing_recipes, + validation_feedback=validation_feedback, + ) + if not recipe: + break + if not validate_candidate: + _skip_recipe_learning(span, "missing_validation_runner") + return + + fixture_args = get_validation_tool_args(recipe) + candidate_result = await validate_candidate(recipe, fixture_args) + if results_equivalent(candidate_result, validation_result): + break + + validation_feedback = _record_validation_mismatch( + span=span, + validation_attempt=validation_attempt + 1, + recipe=recipe, + fixture_args=fixture_args, + candidate_result=candidate_result, + expected_result=validation_result, + ) + + if not recipe: + _skip_recipe_learning(span, "extractor_rejected") + return + + if not results_equivalent(candidate_result, validation_result): + _skip_recipe_learning( + span, + "candidate_result_mismatch", + candidate_rows=_row_count(candidate_result), + expected_rows=_row_count(validation_result), + ) + return + recipe.pop("validation_fixture", None) + + for existing in existing_recipes: + if _recipes_equivalent(existing, recipe): + if existing.get("description"): + _record_recipe_learning(span, "duplicate", recipe_id=existing.get("recipe_id")) + return + tool_name = existing.get("tool_name") or get_recipe_tool_name(existing) + if tool_name: + recipe["public_contract"]["tool_name"] = tool_name + recipe_id = await ASYNC_API_AGENT_STORE.save_or_touch( + api_id=api_id, + schema_hash=schema_hash, + question=question, + recipe=recipe, + tool_name=recipe["public_contract"]["tool_name"], + ) + mark_recipe_changed(recipe_id) + _record_recipe_learning(span, "updated", recipe_id=recipe_id) + return + + seen: set[str] = {r["tool_name"] for r in existing_recipes if r.get("tool_name")} + tool_name = deduplicate_tool_name(get_recipe_tool_name(recipe), seen_names=seen, max_len=40) + recipe["public_contract"]["tool_name"] = tool_name + recipe_id = await ASYNC_API_AGENT_STORE.save_or_touch( + api_id=api_id, + schema_hash=schema_hash, + question=question, + recipe=recipe, + tool_name=tool_name, + ) + mark_recipe_changed(recipe_id) + _record_recipe_learning(span, "saved", recipe_id=recipe_id, tool_name=tool_name) + except Exception: + _record_recipe_learning(span, "error", reason="exception", schema_hash=schema_hash[:12]) + logger.exception( + "Recipe extraction failed api_type=%s api_id=%s schema_hash=%s", + api_type, + api_id[:100], + schema_hash[:12], + ) + + +def _recipe_steps_for_learning(steps: list[Any], span: Any) -> list[Any]: + learning_steps = _drop_empty_intermediate_steps(steps) + pruned_count = len(steps) - len(learning_steps) + if pruned_count: + attributes = {"recipe.pruned_step_count": pruned_count} + _set_recipe_span_attributes(span, attributes) + _add_recipe_span_event(span, "recipe.learning.pruned_steps", attributes) + logger.info("Pruned recipe learning dead-end steps count=%s", pruned_count) + return learning_steps + + +def _extractor_steps_context(steps: list[Any]) -> list[Any]: + return [_extractor_step_context(step) for step in steps] + + +def _extractor_step_context(step: Any) -> Any: + if not isinstance(step, dict): + return step + context = dict(step) + if "result" in context: + context["result"] = _extractor_result_context(context["result"]) + return context + + +def _extractor_result_context(value: Any) -> Any: + if isinstance(value, list) and len(value) > _VALIDATION_SAMPLE_ROWS: + return { + "row_count": len(value), + "sample": value[:_VALIDATION_SAMPLE_ROWS], + "truncated": True, + } + return value + + +def _extractor_existing_recipes_context( + existing_recipes: list[dict[str, Any]], +) -> list[dict[str, Any]]: + context: list[dict[str, Any]] = [] + for recipe in existing_recipes: + public_contract = recipe.get("public_contract") + entry: dict[str, Any] = { + "tool_name": recipe.get("tool_name") or get_recipe_tool_name(recipe), + "question": recipe.get("question", ""), + "description": recipe.get("description", ""), + "tool_args": get_recipe_tool_args(recipe), + } + if isinstance(public_contract, dict): + entry["public_contract"] = { + "tool_name": public_contract.get("tool_name"), + "description": public_contract.get("description"), + "tool_args": dict(get_recipe_tool_args(recipe)), + } + if recipe.get("fingerprint"): + entry["fingerprint"] = recipe.get("fingerprint") + context.append(entry) + return context + + +def _drop_empty_intermediate_steps(steps: list[Any]) -> list[Any]: + if len(steps) < 2: + return steps + + kept: list[Any] = [] + later_has_rows = False + for step in reversed(steps): + is_dead_end_sql = ( + isinstance(step, dict) + and step.get("kind") == "sql" + and step.get("result") == [] + and later_has_rows + ) + if not is_dead_end_sql: + kept.append(step) + later_has_rows = later_has_rows or _step_has_rows(step) + kept.reverse() + return kept + + +def _step_has_rows(step: Any) -> bool: + return isinstance(step, dict) and bool(step.get("result")) + + +def _skip_recipe_learning(span: Any, reason: str, **attributes: Any) -> None: + _record_recipe_learning(span, "skipped", reason=reason, **attributes) + details = " ".join( + f"{key}={_span_attribute_value(value)}" + for key, value in attributes.items() + if value is not None + ) + suffix = f" {details}" if details else "" + logger.info( + "Skipping recipe extraction (%s)%s", + _SKIP_REASON_MESSAGES.get(reason, reason.replace("_", " ")), + suffix, + ) + + +def _record_recipe_learning(span: Any, outcome: str, **attributes: Any) -> None: + event_attributes = {"recipe.learning.outcome": outcome} + reason = attributes.pop("reason", None) + if reason is not None: + reason_key = ( + "recipe.learning.skip_reason" if outcome == "skipped" else "recipe.learning.reason" + ) + event_attributes[reason_key] = reason + for key, value in attributes.items(): + attr_key = key if key.startswith("recipe.") else f"recipe.{key}" + event_attributes[attr_key] = value + + _set_recipe_span_attributes(span, event_attributes) + _add_recipe_span_event(span, f"recipe.learning.{outcome}", event_attributes) + + +def _set_recipe_span_attributes(span: Any, attributes: dict[str, Any]) -> None: + if span is None: + return + for key, value in attributes.items(): + if value is None: + continue + try: + span.set_attribute(key, _span_attribute_value(value)) + except Exception: + logger.debug("Failed to set recipe span attribute %s", key, exc_info=True) + + +def _add_recipe_span_event(span: Any, name: str, attributes: dict[str, Any]) -> None: + if span is None: + return + try: + span.add_event( + name, + {key: _span_attribute_value(value) for key, value in attributes.items()}, + ) + except Exception: + logger.debug("Failed to add recipe span event %s", name, exc_info=True) + + +def _span_attribute_value(value: Any) -> bool | int | float | str: + if isinstance(value, (bool, int, float, str)): + return value + return json.dumps(value, sort_keys=True, default=str) + + +def _row_count(value: Any) -> int: + if isinstance(value, list): + return len(value) + if value is None: + return 0 + return 1 + + +def _record_validation_mismatch( + *, + span: Any, + validation_attempt: int, + recipe: dict[str, Any], + fixture_args: dict[str, Any], + candidate_result: Any, + expected_result: Any, +) -> dict[str, Any]: + feedback = _build_validation_feedback( + recipe=recipe, + fixture_args=fixture_args, + candidate_result=candidate_result, + expected_result=expected_result, + ) + _add_recipe_span_event( + span, + "recipe.learning.validation_mismatch", + { + "recipe.validation_attempt": validation_attempt, + "recipe.candidate_rows": _row_count(candidate_result), + "recipe.expected_rows": _row_count(expected_result), + }, + ) + return feedback + + +def _build_validation_feedback( + *, + recipe: dict[str, Any], + fixture_args: dict[str, Any], + candidate_result: Any, + expected_result: Any, +) -> dict[str, Any]: + return { + "reason": "candidate_result_mismatch", + "previous_candidate": recipe, + "fixture_args": fixture_args, + "candidate_result": { + "row_count": _row_count(candidate_result), + "sample": _sample_result(candidate_result), + }, + "expected_result": { + "row_count": _row_count(expected_result), + "sample": _sample_result(expected_result), + }, + } + + +def _sample_result(value: Any) -> Any: + if isinstance(value, list): + return value[:_VALIDATION_SAMPLE_ROWS] + return value + + +def _validation_result_from_steps(steps: list[Any], fallback: Any) -> Any: + combined = _combined_terminal_step_results(steps) + return combined if combined is not None else fallback + + +def _combined_terminal_step_results(steps: list[Any]) -> list[Any] | None: + if not steps: + return None + + last_key = _repeatable_step_key(steps[-1]) + if last_key is None: + return None + + grouped: list[dict[str, Any]] = [] + for step in reversed(steps): + if not isinstance(step, dict) or _repeatable_step_key(step) != last_key: + break + if not isinstance(step.get("result"), list): + return None + grouped.append(step) + + if len(grouped) < 2: + return None + + rows: list[Any] = [] + for step in reversed(grouped): + rows.extend(step["result"]) + return rows + + +def _repeatable_step_key(step: Any) -> tuple[str, str, str] | None: + if not isinstance(step, dict): + return None + if step.get("kind") != "rest": + return None + method = step.get("method") + path = step.get("path") + if not isinstance(method, str) or not isinstance(path, str): + return None + return ("rest", method.upper(), path) + + +def should_learn_recipe( + *, + api_id: str, + schema_hash: str, + question: str, + learn_rate: float, +) -> bool: + """Deterministically sample recipe extraction.""" + try: + rate = float(learn_rate) + except (TypeError, ValueError): + rate = 0.0 + if rate <= 0: + return False + if rate >= 1: + return True + seed = sha256_hex(f"{api_id}|{schema_hash}|{question}") + bucket = int(seed[:16], 16) / float(0xFFFFFFFFFFFFFFFF) + return bucket < rate + + +async def async_validate_and_prepare_recipe( + recipe_id: str, + params_json: str, + raw_schema_var, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None, str]: + """Async-safe recipe validation for request/tool execution paths.""" + if not _raw_schema_from_var(raw_schema_var): + return None, None, error_json("schema not loaded") + + recipe = await ASYNC_API_AGENT_STORE.get_recipe(recipe_id) + return _validate_loaded_recipe(recipe, recipe_id, params_json) + + +def _raw_schema_from_var(raw_schema_var) -> str: + try: + return raw_schema_var.get() + except LookupError: + return "" + + +def _validate_loaded_recipe( + recipe: dict[str, Any] | None, + recipe_id: str, + params_json: str, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None, str]: + if not recipe or not has_recipe_contract(recipe): + return None, None, error_json(f"recipe not found: {recipe_id}") + + provided, err = _parse_recipe_params(params_json) + if err: + return None, None, err + + validated, err = validate_recipe_params(get_recipe_tool_args(recipe), provided) + if err: + return None, None, err + return recipe, validated, "" + + +def _parse_recipe_params(params_json: str) -> tuple[dict[str, Any], str]: + provided: dict[str, Any] = {} + if params_json: + try: + provided = json.loads(params_json) + except json.JSONDecodeError as e: + return {}, error_json(f"invalid params_json: {e.msg}") + return provided, "" diff --git a/api_agent/recipe/naming.py b/api_agent/recipe/naming.py index 9ab2b79..a6c32b6 100644 --- a/api_agent/recipe/naming.py +++ b/api_agent/recipe/naming.py @@ -5,6 +5,12 @@ def sanitize_tool_name(name: str | None) -> str: """Normalize tool name to a safe slug.""" - slug = re.sub(r"[^\w\s]", "", (name or "").lower()) - slug = re.sub(r"\s+", "_", slug).strip("_") + slug = re.sub(r"[^a-z0-9]+", "_", (name or "").lower()) + slug = re.sub(r"_+", "_", slug).strip("_") + for prefix in ("r_", "api_", "rest_", "graphql_"): + if slug.startswith(prefix): + slug = slug.removeprefix(prefix) + break + if slug and not slug[0].isalpha(): + slug = f"recipe_{slug}" return slug or "recipe" diff --git a/api_agent/recipe/runner.py b/api_agent/recipe/runner.py index 416a471..c50e271 100644 --- a/api_agent/recipe/runner.py +++ b/api_agent/recipe/runner.py @@ -2,25 +2,30 @@ from __future__ import annotations -import json from contextvars import ContextVar from typing import Any from ..agent.graphql_agent import fetch_graphql_schema_raw from ..context import RequestContext -from ..executor import extract_tables_from_response from ..graphql import execute_query as graphql_execute from ..rest.client import execute_request from ..rest.schema_loader import fetch_schema_context +from ..store import ASYNC_API_AGENT_STORE, sha256_hex from ..utils.csv import to_csv -from .common import ( - build_api_id, +from .contracts import get_recipe_tool_args, has_recipe_contract, resolve_recipe_values +from .execution import ( + build_rest_call_record, + collect_step_rows, error_json, execute_recipe_steps, format_recipe_response, + get_rest_step_call, + render_graphql_query_sets, + render_rest_call_sets, + store_step_rows, validate_recipe_params, ) -from .store import RECIPE_STORE, render_param_refs, render_text_template, sha256_hex +from .search import build_api_id async def load_schema_and_base_url(ctx: RequestContext) -> tuple[str, str]: @@ -49,7 +54,7 @@ async def execute_recipe_tool( if not raw_schema: return error_json("schema not loaded") - meta = RECIPE_STORE.get_recipe_meta(recipe_id) + meta = await ASYNC_API_AGENT_STORE.get_recipe_meta(recipe_id) if not meta: return error_json(f"recipe not found: {recipe_id}") @@ -59,11 +64,16 @@ async def execute_recipe_tool( return error_json("recipe does not match current API or schema") recipe = meta.get("recipe") or {} - params_spec = recipe.get("params", {}) + if not has_recipe_contract(recipe): + return error_json(f"recipe not found: {recipe_id}") + params_spec = get_recipe_tool_args(recipe) provided = params or {} - validated_params, error = validate_recipe_params(params_spec, provided) + validated_public_args, error = validate_recipe_params(params_spec, provided) if error: return error + execution_params, error = resolve_recipe_values(recipe, validated_public_args or {}) + if error: + return error_json(error) # Initialize storage for results query_results_var: ContextVar[dict[str, Any]] = ContextVar("recipe_query_results") @@ -78,25 +88,28 @@ async def graphql_step_executor(step_idx, step, params, results): if not isinstance(step, dict) or step.get("kind") != "graphql": return False, None, error_json("invalid recipe step"), None - name = step.get("name") or "data" - tmpl = step.get("query_template") - if not isinstance(tmpl, str): - return False, None, error_json("missing query_template"), None + rendered_queries, render_error = render_graphql_query_sets(step, params, results) + if render_error: + return False, None, error_json(render_error), None - query = render_text_template(tmpl, params) - res = await graphql_execute(query, None, ctx.target_url, ctx.target_headers) - if not res.get("success"): - return False, None, error_json(res.get("error", "query failed")), None + combined_rows: list[Any] = [] + queries: list[str] = [] + for rendered in rendered_queries: + query = rendered.query + res = await graphql_execute(query, None, ctx.target_url, ctx.target_headers) + if not res.get("success"): + return False, None, error_json(res.get("error", "query failed")), None - data = res.get("data", {}) - tables, _ = extract_tables_from_response(data, str(name)) - results.update(tables) + combined_rows.extend(collect_step_rows(res.get("data", {}), step, rendered.binding)) + queries.append(query) + + store_step_rows(results, step, combined_rows) query_results_var.set(results) - return True, tables.get(str(name)), "", query + return True, combined_rows, "", queries success, last_data, executed_sql, error = await execute_recipe_steps( recipe, - validated_params or {}, + execution_params or {}, query_results_var, last_result_var, graphql_step_executor, @@ -121,53 +134,41 @@ async def rest_step_executor(step_idx, step, params, results): if not isinstance(step, dict) or step.get("kind") != "rest": return False, None, error_json("invalid recipe step"), None - method = str(step.get("method", "GET")).upper() - path = str(step.get("path", "")) - name = str(step.get("name") or "data") - - try: - pp = render_param_refs(step.get("path_params") or {}, params) - qp = render_param_refs(step.get("query_params") or {}, params) - bd = render_param_refs(step.get("body") or {}, params) - except KeyError as e: - return False, None, error_json(str(e)), None - - path_params = pp if isinstance(pp, dict) else None - query_params = qp if isinstance(qp, dict) else None - body = bd if isinstance(bd, dict) and bd else None - - res = await execute_request( - method, - path, - path_params, - query_params, - body, - base_url=base_url, - headers=ctx.target_headers, - allow_unsafe_paths=list(ctx.allow_unsafe_paths), - ) - if not res.get("success"): - return False, None, error_json(res.get("error", "request failed")), None + method, path, name = get_rest_step_call(step) + + rendered_sets, render_error = render_rest_call_sets(step, params, results) + if render_error: + return False, None, error_json(render_error), None + + combined_rows: list[Any] = [] + call_recs: list[dict[str, Any]] = [] + for rendered in rendered_sets: + res = await execute_request( + method, + path, + rendered.path_params, + rendered.query_params, + rendered.body, + base_url=base_url, + headers=ctx.target_headers, + allow_unsafe_paths=list(ctx.allow_unsafe_paths), + ) + if not res.get("success"): + return False, None, error_json(res.get("error", "request failed")), None - data = res.get("data", {}) - tables, _ = extract_tables_from_response(data, name) - results.update(tables) - query_results_var.set(results) + combined_rows.extend(collect_step_rows(res.get("data", {}), step, rendered.binding)) - call_rec = { - "method": method, - "path": path, - "path_params": json.dumps(path_params) if path_params else "", - "query_params": json.dumps(query_params) if query_params else "", - "body": json.dumps(body) if body else "", - "name": name, - "success": True, - } - return True, tables.get(name), "", call_rec + call_recs.append( + build_rest_call_record(method=method, path=path, name=name, rendered=rendered) + ) + + store_step_rows(results, step, combined_rows) + query_results_var.set(results) + return True, combined_rows, "", call_recs success, last_data, executed_sql, error = await execute_recipe_steps( recipe, - validated_params or {}, + execution_params or {}, query_results_var, last_result_var, rest_step_executor, diff --git a/api_agent/recipe/search.py b/api_agent/recipe/search.py new file mode 100644 index 0000000..67e5fc6 --- /dev/null +++ b/api_agent/recipe/search.py @@ -0,0 +1,121 @@ +"""Recipe lookup and prompt context formatting.""" + +from __future__ import annotations + +from typing import Any + +from ..store import ASYNC_API_AGENT_STORE, sha256_hex +from .contracts import ( + get_recipe_steps, + get_recipe_tool_args, + get_recipe_tool_name, + has_recipe_contract, +) +from .tooling import _sanitize_for_tool_name + + +def build_api_id(ctx, api_type: str, base_url: str = "") -> str: + """Build api_id string for recipe matching.""" + if api_type == "graphql": + return f"graphql:{ctx.target_url}" + return f"rest:{ctx.target_url}|{base_url}" + + +def _score_hint(score: float) -> str: + """Get human-readable hint for recipe match score.""" + if score >= 0.8: + return "STRONG MATCH - highly recommended" + if score >= 0.6: + return "Good match - verify params" + return "Possible match - check alignment" + + +def _steps_summary(steps: list) -> str: + """Build step summary string.""" + parts = [] + api_count = 0 + sql_count = 0 + for step in steps: + if isinstance(step, dict) and step.get("kind") == "sql": + sql_count += 1 + else: + api_count += 1 + + if api_count: + parts.append(f"{api_count} API call{'s' if api_count > 1 else ''}") + if sql_count: + parts.append(f"{sql_count} SQL step{'s' if sql_count > 1 else ''}") + return " + ".join(parts) if parts else "no steps" + + +async def search_recipes( + api_id: str, + raw_schema: str, + question: str, + k: int = 3, +) -> tuple[list[dict[str, Any]], str]: + """Search for matching recipes and build context string.""" + if not raw_schema: + return [], "" + + schema_hash = sha256_hex(raw_schema) + suggestions = await ASYNC_API_AGENT_STORE.suggest_recipes( + api_id=api_id, + schema_hash=schema_hash, + question=question, + k=k, + ) + if not suggestions: + return [], "" + + contract_suggestions: list[dict[str, Any]] = [] + for suggestion in suggestions: + recipe = await ASYNC_API_AGENT_STORE.get_recipe(suggestion["recipe_id"]) + if not recipe or not has_recipe_contract(recipe): + continue + contract_suggestions.append( + { + **suggestion, + "recipe": recipe, + "params": get_recipe_tool_args(recipe), + "tool_name": get_recipe_tool_name(recipe), + } + ) + + return contract_suggestions, build_recipe_context(contract_suggestions) + + +def build_recipe_context(suggestions: list[dict[str, Any]]) -> str: + """Build recipe context for system prompt.""" + if not suggestions: + return "" + + lines = ["\n", "Available recipe tools (sorted by relevance):"] + + for idx, suggestion in enumerate(suggestions, 1): + recipe = suggestion.get("recipe") + if not recipe: + continue + + if not has_recipe_contract(recipe): + continue + + params_spec = get_recipe_tool_args(recipe) + param_list = [] + for key, spec in params_spec.items(): + if isinstance(spec, dict): + typ = spec.get("type", "str") + param_list.append(f"{key}: {typ}") + else: + param_list.append(f"{key}: str") + + tool_name = get_recipe_tool_name(recipe) or _sanitize_for_tool_name(suggestion["question"]) + score = suggestion["score"] + + lines.append(f"\n{idx}. {tool_name}({', '.join(param_list)})") + lines.append(f' Question: "{suggestion["question"]}"') + lines.append(f" Score: {score:.2f} ({_score_hint(score)})") + lines.append(f" Steps: {_steps_summary(get_recipe_steps(recipe))}") + + lines.append("") + return "\n".join(lines) diff --git a/api_agent/recipe/state.py b/api_agent/recipe/state.py new file mode 100644 index 0000000..025435b --- /dev/null +++ b/api_agent/recipe/state.py @@ -0,0 +1,77 @@ +"""Per-request recipe runtime state.""" + +from __future__ import annotations + +from contextvars import ContextVar +from typing import Any + +from agents import FunctionToolResult, RunContextWrapper +from agents.agent import ToolsToFinalOutputResult + +_recipes_changed: ContextVar[list[str]] = ContextVar("recipes_changed") +_return_directly_flag: ContextVar[list[bool]] = ContextVar("return_directly_flag") +_recipe_tools_used: ContextVar[list[str]] = ContextVar("recipe_tools_used") + + +def reset_recipe_change_flag() -> None: + """Reset recipe change tracking for the current request.""" + _recipes_changed.set([]) + + +def mark_recipe_changed(recipe_id: str) -> None: + """Record that a recipe was created during the current request.""" + try: + _recipes_changed.get().append(recipe_id) + except LookupError: + _recipes_changed.set([recipe_id]) + + +def consume_recipe_changes() -> list[str]: + """Consume and clear recipe change tracking.""" + try: + changes = list(_recipes_changed.get()) + except LookupError: + return [] + _recipes_changed.set([]) + return changes + + +def reset_recipe_tool_usage() -> None: + """Reset recipe tool usage tracking for the current agent run.""" + _recipe_tools_used.set([]) + + +def mark_recipe_tool_used(recipe_id: str) -> None: + """Record that the agent used an injected recipe tool.""" + try: + _recipe_tools_used.get().append(recipe_id) + except LookupError: + _recipe_tools_used.set([recipe_id]) + + +def recipe_tool_was_used() -> bool: + """Return whether an injected recipe tool ran in this agent run.""" + try: + return bool(_recipe_tools_used.get()) + except LookupError: + return False + + +def _set_return_directly() -> None: + """Signal that tool result should be returned directly.""" + try: + _return_directly_flag.get().append(True) + except LookupError: + pass + + +def _tools_to_final_output( + context: RunContextWrapper[Any], tool_results: list[FunctionToolResult] +) -> ToolsToFinalOutputResult: + """Return tool output directly when a recipe requested it.""" + try: + if _return_directly_flag.get(): + return ToolsToFinalOutputResult(is_final_output=True, final_output="__DIRECT_RETURN__") + except LookupError: + pass + return ToolsToFinalOutputResult(is_final_output=False, final_output=None) diff --git a/api_agent/recipe/store.py b/api_agent/recipe/store.py deleted file mode 100644 index de3cf93..0000000 --- a/api_agent/recipe/store.py +++ /dev/null @@ -1,351 +0,0 @@ -"""In-process recipe cache for reusing parameterized API-call + SQL pipelines.""" - -from __future__ import annotations - -import hashlib -import json -import logging -import re -import threading -import time -import uuid -from collections import OrderedDict, defaultdict -from dataclasses import dataclass -from typing import Any - -from rapidfuzz import fuzz - -from ..config import settings -from .naming import sanitize_tool_name - -logger = logging.getLogger(__name__) - - -def _log_recipe(msg: str) -> None: - """Log recipe activity only in debug mode.""" - if settings.DEBUG: - logger.info(f"[Recipe] {msg}") - - -def sha256_hex(text: str) -> str: - """Hash text, normalizing JSON when possible for stable schema hashes.""" - normalized = text - try: - parsed = json.loads(text) - except Exception: - parsed = None - if parsed is not None: - normalized = json.dumps(parsed, sort_keys=True, separators=(",", ":"), ensure_ascii=True) - return hashlib.sha256(normalized.encode("utf-8")).hexdigest() - - -_PLACEHOLDER_RE = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}") - - -def normalize_ws(text: str) -> str: - """Whitespace-normalize for template equivalence checks.""" - return re.sub(r"\s+", " ", (text or "")).strip() - - -def render_text_template(template: str, params: dict[str, Any]) -> str: - """Render {{param}} placeholders using raw string insertion (template carries quoting).""" - - def _as_text(v: Any) -> str: - if isinstance(v, bool): - return "true" if v else "false" - if v is None: - return "null" - return str(v) - - def repl(m: re.Match[str]) -> str: - name = m.group(1) - if name not in params: - raise KeyError(f"missing param: {name}") - return _as_text(params[name]) - - return _PLACEHOLDER_RE.sub(repl, template) - - -def render_param_refs(obj: Any, params: dict[str, Any]) -> Any: - """Recursively replace {'$param': 'x'} nodes with params['x'].""" - if isinstance(obj, dict): - if set(obj.keys()) == {"$param"} and isinstance(obj.get("$param"), str): - pname = obj["$param"] - if pname not in params: - raise KeyError(f"missing param: {pname}") - return params[pname] - return {k: render_param_refs(v, params) for k, v in obj.items()} - if isinstance(obj, list): - return [render_param_refs(v, params) for v in obj] - return obj - - -def get_example_values( - params_spec: dict[str, Any] | None, provided: dict[str, Any] | None -) -> dict[str, Any]: - """Extract example values from param specs, overlaid with provided values. - - Stored "default" values are examples from the original execution, not - runtime fallbacks. Used by the extractor to verify template equivalence. - """ - out: dict[str, Any] = {} - if isinstance(params_spec, dict): - for pname, spec in params_spec.items(): - if isinstance(spec, dict) and "default" in spec: - out[pname] = spec["default"] - if isinstance(provided, dict): - out.update(provided) - return out - - -def _normalize_question(q: str) -> str: - return re.sub(r"\s+", " ", (q or "").strip().lower()) - - -def _tokens(q: str) -> set[str]: - return set(re.findall(r"[a-z0-9]+", _normalize_question(q))) - - -def _similarity(query: str, signature: str) -> float: - """Similarity score using RapidFuzz token-based matching.""" - q_norm = _normalize_question(query) - s_norm = _normalize_question(signature) - if not q_norm or not s_norm: - return 0.0 - if q_norm == s_norm: - return 1.0 - - q_tokens = _tokens(query) - s_tokens = _tokens(signature) - if not q_tokens or not s_tokens: - return 0.0 - - q_text = " ".join(sorted(q_tokens)) - s_text = " ".join(sorted(s_tokens)) - base = fuzz.token_set_ratio(q_text, s_text) - - partial_fn = getattr(fuzz, "partial_token_set_ratio", None) - extra: float = ( - partial_fn(q_text, s_text) if callable(partial_fn) else fuzz.WRatio(q_text, s_text) - ) - - overlap = len(q_tokens & s_tokens) / max(len(q_tokens), 1) - coverage = len(s_tokens & q_tokens) / max(len(s_tokens), 1) - token_balance = min(overlap, coverage) * 100.0 - - return (0.55 * base + 0.25 * extra + 0.20 * token_balance) / 100.0 - - -@dataclass -class RecipeRecord: - recipe_id: str - api_id: str - schema_hash: str - question: str - question_sig: str - question_tokens: set[str] - recipe: dict[str, Any] - tool_name: str # LLM-generated function name for this recipe - created_at: float - last_used_at: float - - -class RecipeStore: - """Thread-safe global recipe store with simple intent matching and LRU eviction.""" - - def __init__(self, max_size: int = 64) -> None: - self._max_size = max(1, int(max_size)) - self._lock = threading.Lock() - self._records: dict[str, RecipeRecord] = {} - self._by_key: dict[tuple[str, str], set[str]] = defaultdict(set) - self._lru: OrderedDict[str, None] = OrderedDict() - - def save_recipe( - self, - *, - api_id: str, - schema_hash: str, - question: str, - recipe: dict[str, Any], - tool_name: str, - ) -> str: - recipe_id = f"r_{uuid.uuid4().hex[:8]}" - now = time.time() - record = RecipeRecord( - recipe_id=recipe_id, - api_id=api_id, - schema_hash=schema_hash, - question=question, - question_sig=_normalize_question(question), - question_tokens=_tokens(question), - recipe=dict(recipe), - tool_name=tool_name, - created_at=now, - last_used_at=now, - ) - - with self._lock: - self._records[recipe_id] = record - self._by_key[(api_id, schema_hash)].add(recipe_id) - self._touch(recipe_id) - self._evict_if_needed() - - params = list(recipe.get("params", {}).keys()) - _log_recipe(f"SAVE {recipe_id} params={params} q={question[:40]}") - return recipe_id - - def get_recipe(self, recipe_id: str) -> dict[str, Any] | None: - with self._lock: - rec = self._records.get(recipe_id) - if not rec: - return None - rec.last_used_at = time.time() - self._touch(recipe_id) - return dict(rec.recipe) - - def get_recipe_meta(self, recipe_id: str) -> dict[str, Any] | None: - """Return {api_id, schema_hash, recipe} for safety checks.""" - with self._lock: - rec = self._records.get(recipe_id) - if not rec: - return None - rec.last_used_at = time.time() - self._touch(recipe_id) - return { - "api_id": rec.api_id, - "schema_hash": rec.schema_hash, - "recipe": dict(rec.recipe), - } - - def suggest_recipes( - self, - *, - api_id: str, - schema_hash: str, - question: str, - k: int = 3, - ) -> list[dict[str, Any]]: - q_sig = _normalize_question(question) - key = (api_id, schema_hash) - with self._lock: - ids = list(self._by_key.get(key, set())) - recs = [self._records[i] for i in ids if i in self._records] - - scored: list[tuple[float, RecipeRecord]] = [] - for r in recs: - score = _similarity(q_sig, r.question_sig) - if score > 0: - scored.append((score, r)) - - scored.sort(key=lambda t: (t[0], t[1].last_used_at), reverse=True) - out: list[dict[str, Any]] = [] - for score, r in scored[: max(0, int(k))]: - out.append( - { - "recipe_id": r.recipe_id, - "score": round(score, 4), - "created_at": r.created_at, - "last_used_at": r.last_used_at, - "question": r.question, - "tool_name": r.tool_name, - } - ) - - if out: - matches = " ".join(f"{s['recipe_id']}({s['score']:.2f})" for s in out) - _log_recipe(f"SUGGEST found={len(out)} [{matches}]") - return out - - def list_recipes( - self, - *, - api_id: str, - schema_hash: str, - ) -> list[dict[str, Any]]: - """List recipes for a given api_id + schema_hash.""" - key = (api_id, schema_hash) - with self._lock: - ids = list(self._by_key.get(key, set())) - recs = [self._records[i] for i in ids if i in self._records] - - recs.sort(key=lambda r: r.last_used_at, reverse=True) - out: list[dict[str, Any]] = [] - for r in recs: - out.append( - { - "recipe_id": r.recipe_id, - "question": r.question, - "tool_name": r.tool_name, - "created_at": r.created_at, - "last_used_at": r.last_used_at, - "params": dict(r.recipe.get("params", {})), - "steps": list(r.recipe.get("steps", [])), - "sql_steps": list(r.recipe.get("sql_steps", [])), - } - ) - return out - - def find_recipe_by_tool_slug( - self, - *, - api_id: str, - schema_hash: str, - tool_slug: str, - max_slug_len: int | None = None, - ) -> dict[str, Any] | None: - """Find the most recent recipe by tool_slug for an api_id + schema_hash.""" - key = (api_id, schema_hash) - if not (isinstance(max_slug_len, int) and max_slug_len > 0): - max_slug_len = None - - with self._lock: - ids = list(self._by_key.get(key, set())) - recs: list[RecipeRecord] = [] - for i in ids: - rec = self._records.get(i) - if not rec: - continue - slug = sanitize_tool_name(rec.tool_name) - if max_slug_len: - slug = slug[:max_slug_len] - if slug == tool_slug: - recs.append(rec) - - if not recs: - return None - recs.sort(key=lambda r: (r.last_used_at, r.created_at), reverse=True) - r = recs[0] - return { - "recipe_id": r.recipe_id, - "question": r.question, - "tool_name": r.tool_name, - "created_at": r.created_at, - "last_used_at": r.last_used_at, - "params": dict(r.recipe.get("params", {})), - "steps": list(r.recipe.get("steps", [])), - "sql_steps": list(r.recipe.get("sql_steps", [])), - } - - def _touch(self, recipe_id: str) -> None: - self._lru.pop(recipe_id, None) - self._lru[recipe_id] = None - - def _evict_if_needed(self) -> None: - while len(self._records) > self._max_size and self._lru: - oldest_id = next(iter(self._lru)) - self._delete(oldest_id) - - def _delete(self, recipe_id: str) -> None: - rec = self._records.pop(recipe_id, None) - self._lru.pop(recipe_id, None) - if not rec: - return - key = (rec.api_id, rec.schema_hash) - ids = self._by_key.get(key) - if ids: - ids.discard(recipe_id) - if not ids: - self._by_key.pop(key, None) - - -RECIPE_STORE = RecipeStore(max_size=settings.RECIPE_CACHE_SIZE) diff --git a/api_agent/recipe/templates.py b/api_agent/recipe/templates.py new file mode 100644 index 0000000..a7a0c45 --- /dev/null +++ b/api_agent/recipe/templates.py @@ -0,0 +1,41 @@ +"""Recipe template rendering helpers.""" + +from __future__ import annotations + +import re +from typing import Any + +_PLACEHOLDER_RE = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}") + + +def render_text_template(template: str, params: dict[str, Any]) -> str: + """Render {{param}} placeholders using raw string insertion.""" + + def _as_text(v: Any) -> str: + if isinstance(v, bool): + return "true" if v else "false" + if v is None: + return "null" + return str(v) + + def repl(m: re.Match[str]) -> str: + name = m.group(1) + if name not in params: + raise KeyError(f"missing param: {name}") + return _as_text(params[name]) + + return _PLACEHOLDER_RE.sub(repl, template) + + +def render_param_refs(obj: Any, params: dict[str, Any]) -> Any: + """Recursively replace {'$var': 'x'} nodes with params['x'].""" + if isinstance(obj, dict): + if set(obj.keys()) == {"$var"} and isinstance(obj.get("$var"), str): + pname = obj["$var"] + if pname not in params: + raise KeyError(f"missing param: {pname}") + return params[pname] + return {k: render_param_refs(v, params) for k, v in obj.items()} + if isinstance(obj, list): + return [render_param_refs(v, params) for v in obj] + return obj diff --git a/api_agent/recipe/tooling.py b/api_agent/recipe/tooling.py new file mode 100644 index 0000000..fcd86cd --- /dev/null +++ b/api_agent/recipe/tooling.py @@ -0,0 +1,73 @@ +"""Recipe MCP tool schema, naming, and descriptions.""" + +from __future__ import annotations + +import re +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, create_model + +_LEADING_CONTEXT_RE = re.compile(r"^\[[^\]]+\]\s*") + + +def build_recipe_docstring( + question: str, + steps: list, + api_type: str = "rest", + params_spec: dict[str, Any] | None = None, + description: str | None = None, +) -> str: + """Build docstring for recipe tool.""" + return _normalize_description(description or "") + + +def _normalize_description(description: str) -> str: + """Normalize generated tool descriptions for MCP clients.""" + return _LEADING_CONTEXT_RE.sub("", " ".join(description.split())) + + +def create_params_model(pspec: dict[str, Any], tname: str) -> type[BaseModel]: + """Create Pydantic model for recipe params with strict validation.""" + + class StrictBase(BaseModel): + model_config = ConfigDict(extra="forbid") + + type_map = {"str": str, "int": int, "float": float, "bool": bool} + field_defs: dict[str, tuple[Any, Any]] = {} + for pname, pinfo in pspec.items(): + py_type = type_map.get(pinfo.get("type", "str"), str) + desc = pinfo.get("description") or "Required" + field_defs[pname] = (py_type, Field(..., description=desc)) + + return create_model(f"{tname}_Params", __base__=StrictBase, **field_defs) # ty: ignore[no-matching-overload] + + +def deduplicate_tool_name(base_name: str, seen_names: set[str], max_len: int = 40) -> str: + """Ensure unique tool name within length limit.""" + base = re.sub(r"[^a-z0-9_]", "", base_name)[:max_len] + if not base or not re.match(r"^[a-z][a-z0-9_]*$", base): + base = "recipe" + + if base not in seen_names: + seen_names.add(base) + return base + + counter = 2 + while True: + suffix = f"_{counter}" + trimmed = base[: max_len - len(suffix)] + candidate = f"{trimmed}{suffix}" + if candidate not in seen_names: + seen_names.add(candidate) + return candidate + counter += 1 + + +def _sanitize_for_tool_name(question: str) -> str: + """Convert question to valid Python identifier (max 40 chars).""" + name = re.sub(r"[^\w\s]", "", question.lower()) + name = re.sub(r"\s+", "_", name) + name = name[:40].strip("_") + if name and name[0].isdigit(): + name = "r_" + name + return name diff --git a/api_agent/rest/client.py b/api_agent/rest/client.py index a84995a..44fe3ad 100644 --- a/api_agent/rest/client.py +++ b/api_agent/rest/client.py @@ -7,14 +7,47 @@ import httpx -from ..utils.http_errors import build_http_error_response - logger = logging.getLogger(__name__) # Unsafe HTTP methods (blocked by default) _UNSAFE_METHODS = {"POST", "PUT", "DELETE", "PATCH"} +def _extract_http_error_details(response: httpx.Response | None) -> Any | None: + """Extract bounded error detail from non-2xx responses.""" + if response is None: + return None + + try: + payload = response.json() + except Exception: + payload = None + + if payload is not None: + if isinstance(payload, dict): + for key in ("errors", "error", "message"): + if key in payload: + return payload[key] + return payload + + raw = response.content[:1500] if response.content else b"" + text = raw.decode("utf-8", errors="replace").strip() if raw else "" + return text[:1000] if text else None + + +def _build_http_error_response(e: httpx.HTTPStatusError) -> dict[str, Any]: + status_code = e.response.status_code if e.response is not None else 0 + response: dict[str, Any] = { + "success": False, + "error": f"HTTP {status_code}", + "status_code": status_code, + } + details = _extract_http_error_details(e.response) + if details is not None: + response["details"] = details + return response + + def _is_path_allowed(path: str, patterns: list[str]) -> bool: """Check if path matches any allowed pattern (fnmatch glob).""" for pattern in patterns: @@ -55,7 +88,7 @@ def _build_url( # Filter out None values filtered = {k: v for k, v in query_params.items() if v is not None} if filtered: - url = f"{url}?{urlencode(filtered)}" + url = f"{url}?{urlencode(filtered, doseq=True)}" return url @@ -140,7 +173,7 @@ async def execute_request( return {"success": True, "data": data} except httpx.HTTPStatusError as e: - return build_http_error_response(e) + return _build_http_error_response(e) except Exception as e: logger.exception("REST API error") return {"success": False, "error": str(e)} diff --git a/api_agent/rest/polling.py b/api_agent/rest/polling.py new file mode 100644 index 0000000..6583e1e --- /dev/null +++ b/api_agent/rest/polling.py @@ -0,0 +1,183 @@ +"""REST polling tool factory.""" + +from __future__ import annotations + +import asyncio +import json +from contextvars import ContextVar +from typing import Any + +from agents import function_tool + +from ..config import settings +from ..context import RequestContext +from ..executor import extract_tables_from_response, truncate_for_context +from .client import execute_request + +_default_rest_calls: ContextVar[list[dict[str, Any]]] = ContextVar("poll_rest_calls") +_default_query_results: ContextVar[dict[str, Any]] = ContextVar("poll_query_results") +_default_last_result: ContextVar[list] = ContextVar("poll_last_result") + + +def _get_nested_value(data: dict | None, path: str) -> Any: + if not data or not path: + return None + current: Any = data + for key in path.split("."): + if not isinstance(current, (dict, list)): + return None + if isinstance(current, list) and key.isdigit(): + idx = int(key) + if not 0 <= idx < len(current): + return None + current = current[idx] + elif isinstance(current, dict): + current = current.get(key) + else: + return None + if current is None: + return None + return current + + +def _append_contextvar_list(var: ContextVar[list[dict[str, Any]]], item: dict[str, Any]) -> None: + try: + var.get().append(item) + except LookupError: + pass + + +def _store_poll_result( + data: Any, + name: str, + query_results_var: ContextVar[dict[str, Any]], + last_result_var: ContextVar[list], +) -> None: + try: + results = query_results_var.get() + tables, _ = extract_tables_from_response(data, name) + results.update(tables) + stored = tables.get(name) + if stored is not None: + last_result_var.get()[0] = stored + except LookupError: + pass + + +def _poll_wait_ms(delay_ms: int) -> int: + requested = delay_ms if delay_ms > 0 else settings.DEFAULT_POLL_DELAY_MS + return max(0, min(requested, settings.MAX_POLL_DELAY_MS)) + + +def create_poll_tool( + ctx: RequestContext, + base_url: str, + *, + rest_calls_var: ContextVar[list[dict[str, Any]]] = _default_rest_calls, + query_results_var: ContextVar[dict[str, Any]] = _default_query_results, + last_result_var: ContextVar[list] = _default_last_result, +): + """Create poll_until_done tool with bound request context.""" + + @function_tool + async def poll_until_done( + method: str, + path: str, + done_field: str, + done_value: str, + body: str = "", + path_params: str = "", + query_params: str = "", + name: str = "poll_result", + delay_ms: int = 0, + ) -> str: + """Poll endpoint until done_field equals done_value.""" + path_params_dict = json.loads(path_params) if path_params else None + query_params_dict = json.loads(query_params) if query_params else None + try: + body_dict = json.loads(body) if body else {} + except json.JSONDecodeError as e: + return json.dumps({"success": False, "error": f"Invalid body JSON: {e.msg}"}) + + max_polls = settings.MAX_POLLS + wait_ms = _poll_wait_ms(delay_ms) + current = None + + attempt = 0 + while attempt < max_polls: + attempt += 1 + + result = await execute_request( + method, + path, + path_params_dict, + query_params_dict, + body=body_dict if body_dict else None, + base_url=base_url, + headers=ctx.target_headers, + allow_unsafe_paths=list(ctx.allow_unsafe_paths), + ) + + _append_contextvar_list( + rest_calls_var, + { + "method": method, + "path": path, + "path_params": path_params, + "query_params": query_params, + "body": json.dumps(body_dict) if body_dict else "", + "name": name, + "poll_attempt": attempt, + "success": bool(result.get("success")), + }, + ) + + if not result.get("success"): + return json.dumps( + { + "success": False, + "error": result.get("error"), + "attempt": attempt, + } + ) + + data = result.get("data", {}) + current = _get_nested_value(data, done_field) + if current is None and attempt == 1: + keys = list(data.keys()) if isinstance(data, dict) else [] + return json.dumps( + { + "success": False, + "error": f"done_field '{done_field}' not found in response. Available keys: {keys}", + } + ) + + if str(current).lower() == done_value.lower(): + _store_poll_result(data, name, query_results_var, last_result_var) + return json.dumps( + { + "success": True, + **truncate_for_context(data if isinstance(data, list) else [data], name), + "attempts": attempt, + }, + indent=2, + ) + + if attempt >= max_polls: + break + + if wait_ms > 0: + await asyncio.sleep(wait_ms / 1000) + + if body_dict.get("polling", {}).get("count") is not None: + body_dict["polling"]["count"] += 1 + + return json.dumps( + { + "success": False, + "error": f"max_polls ({max_polls}) exceeded. Last {done_field} value: {current} (expected: {done_value})", + "attempts": attempt, + } + ) + + return poll_until_done diff --git a/api_agent/rest/schema_loader.py b/api_agent/rest/schema_loader.py index 571b3fb..8ccea93 100644 --- a/api_agent/rest/schema_loader.py +++ b/api_agent/rest/schema_loader.py @@ -3,6 +3,7 @@ import json import logging from typing import Any +from urllib.parse import urlparse import httpx import yaml @@ -41,7 +42,7 @@ def _swagger_param_to_oas3(param: Any) -> dict[str, Any] | None: if param.get("in") == "body": return None - converted = _rewrite_refs(param) + converted = _rewrite_refs(dict(param)) if "schema" in converted and isinstance(converted["schema"], dict): return converted @@ -49,7 +50,7 @@ def _swagger_param_to_oas3(param: Any) -> dict[str, Any] | None: schema: dict[str, Any] = {} for key in ["type", "format", "items", "enum", "default", "minimum", "maximum"]: if key in converted: - schema[key] = converted[key] # already rewritten + schema[key] = _rewrite_refs(converted[key]) if schema: converted["schema"] = schema return converted @@ -81,7 +82,7 @@ def _swagger_responses_to_oas3(responses: Any) -> dict[str, Any]: for code, resp in responses.items(): if not isinstance(resp, dict): continue - converted = _rewrite_refs(resp) + converted = _rewrite_refs(dict(resp)) schema = converted.pop("schema", None) if isinstance(schema, dict): converted["content"] = {"application/json": {"schema": schema}} @@ -99,7 +100,7 @@ def _swagger_security_to_oas3(security_definitions: Any) -> dict[str, Any]: if not isinstance(scheme, dict): continue scheme_type = scheme.get("type", "") - converted = _rewrite_refs(scheme) + converted = _rewrite_refs(dict(scheme)) if scheme_type == "basic": converted = {"type": "http", "scheme": "basic"} elif scheme_type == "oauth2": @@ -152,7 +153,7 @@ def _swagger_servers_from_spec(swagger_spec: dict[str, Any]) -> list[dict[str, s return [{"url": f"{s}://{host}{base_path}"} for s in scheme_list] -def normalize_swagger2_to_oas3(swagger_spec: dict[str, Any]) -> dict[str, Any]: +def _normalize_swagger2_to_oas3(swagger_spec: dict[str, Any]) -> dict[str, Any]: """Normalize Swagger 2.0 spec into minimal OpenAPI 3.x structure.""" out: dict[str, Any] = { "openapi": "3.0.3", @@ -199,7 +200,7 @@ def normalize_swagger2_to_oas3(swagger_spec: dict[str, Any]) -> dict[str, Any]: if not isinstance(op, dict): continue - new_op = _rewrite_refs(op) + new_op = _rewrite_refs(dict(op)) raw_params = op.get("parameters", []) if not isinstance(raw_params, list): raw_params = [] @@ -260,7 +261,7 @@ async def load_openapi_spec( logger.warning("OpenAPI spec root is not an object") return {} - # Validate/normalize API schema shape + # Validate/normalize OpenAPI shape openapi_version = spec.get("openapi", "") if isinstance(openapi_version, str) and openapi_version.startswith("3."): return spec @@ -268,7 +269,7 @@ async def load_openapi_spec( swagger_version = spec.get("swagger", "") if isinstance(swagger_version, str) and swagger_version.startswith("2."): logger.info("Detected Swagger 2.0 spec, normalizing to OpenAPI 3.0 shape") - return normalize_swagger2_to_oas3(spec) + return _normalize_swagger2_to_oas3(spec) logger.warning( f"Unsupported API schema version. openapi={openapi_version!r}, swagger={swagger_version!r}" @@ -292,8 +293,6 @@ def get_base_url_from_spec(spec: dict[str, Any], spec_url: str = "") -> str: # Fallback: derive from spec URL (e.g., https://api.example.com/openapi.json -> https://api.example.com) if spec_url: - from urllib.parse import urlparse - parsed = urlparse(spec_url) return f"{parsed.scheme}://{parsed.netloc}" diff --git a/api_agent/store.py b/api_agent/store.py new file mode 100644 index 0000000..6bf99fb --- /dev/null +++ b/api_agent/store.py @@ -0,0 +1,835 @@ +"""Storage for API Agent recipes and derived API metadata.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import threading +import time +import uuid +from collections import defaultdict +from collections.abc import Callable +from dataclasses import asdict, dataclass +from functools import partial +from typing import Any, Protocol, TypeVar, cast + +from rapidfuzz import fuzz + +from .config import settings +from .recipe.contracts import ( + get_recipe_description, + get_recipe_steps, + get_recipe_tool_args, + get_recipe_tool_name, +) +from .recipe.identity import recipe_fingerprint, sha256_hex +from .recipe.naming import sanitize_tool_name + +logger = logging.getLogger(__name__) +StoreResult = TypeVar("StoreResult") + + +def _log_recipe(msg: str) -> None: + if settings.DEBUG: + logger.info(f"[Recipe] {msg}") + + +def _normalize_question(q: str) -> str: + return re.sub(r"\s+", " ", (q or "").strip().lower()) + + +def _tokens(q: str) -> set[str]: + return set(re.findall(r"[a-z0-9]+", _normalize_question(q))) + + +def _similarity(query: str, signature: str) -> float: + """Similarity score using RapidFuzz token-based matching.""" + q_norm = _normalize_question(query) + s_norm = _normalize_question(signature) + if not q_norm or not s_norm: + return 0.0 + if q_norm == s_norm: + return 1.0 + + q_tokens = _tokens(query) + s_tokens = _tokens(signature) + if not q_tokens or not s_tokens: + return 0.0 + + q_text = " ".join(sorted(q_tokens)) + s_text = " ".join(sorted(s_tokens)) + base = fuzz.token_set_ratio(q_text, s_text) + + partial_fn = getattr(fuzz, "partial_token_set_ratio", None) + extra: float = ( + partial_fn(q_text, s_text) if callable(partial_fn) else fuzz.WRatio(q_text, s_text) + ) + + overlap = len(q_tokens & s_tokens) / max(len(q_tokens), 1) + coverage = len(s_tokens & q_tokens) / max(len(s_tokens), 1) + token_balance = min(overlap, coverage) * 100.0 + + return (0.55 * base + 0.25 * extra + 0.20 * token_balance) / 100.0 + + +@dataclass +class RecipeRecord: + recipe_id: str + api_id: str + schema_hash: str + question: str + question_sig: str + question_tokens: set[str] + recipe: dict[str, Any] + tool_name: str + fingerprint: str + created_at: float + last_used_at: float + insertion_index: int + enabled: bool = True + + +class ApiAgentStoreProtocol(Protocol): + def save_or_touch( + self, + *, + api_id: str, + schema_hash: str, + question: str, + recipe: dict[str, Any], + tool_name: str, + ) -> str: ... + + def save_recipe( + self, + *, + api_id: str, + schema_hash: str, + question: str, + recipe: dict[str, Any], + tool_name: str, + ) -> str: ... + + def get_recipe(self, recipe_id: str) -> dict[str, Any] | None: ... + + def get_recipe_meta(self, recipe_id: str) -> dict[str, Any] | None: ... + + def list_recipes( + self, *, api_id: str, schema_hash: str, include_disabled: bool = False + ) -> list[dict[str, Any]]: ... + + def suggest_recipes( + self, *, api_id: str, schema_hash: str, question: str, k: int = 3 + ) -> list[dict[str, Any]]: ... + + def find_recipe_by_tool_slug( + self, *, api_id: str, schema_hash: str, tool_slug: str, max_slug_len: int | None = None + ) -> dict[str, Any] | None: ... + + def get_downstream_description(self, *, api_id: str, schema_hash: str) -> str | None: ... + + def save_downstream_description( + self, *, api_id: str, schema_hash: str, description: str, ttl_seconds: int | None = None + ) -> None: ... + + def disable_recipe(self, recipe_id: str) -> bool: ... + + def delete_recipe(self, recipe_id: str) -> bool: ... + + def merge_recipes(self, source_id: str, target_id: str) -> bool: ... + + +class MemoryApiAgentStore: + """Thread-safe API Agent store with recipe and downstream description caches.""" + + def __init__(self, max_size: int = 1000) -> None: + self._max_size = max(1, int(max_size)) + self._lock = threading.Lock() + self._records: dict[str, RecipeRecord] = {} + self._by_key: dict[tuple[str, str], set[str]] = defaultdict(set) + self._by_fingerprint: dict[str, str] = {} + self._downstream_descriptions: dict[tuple[str, str], tuple[str, float | None]] = {} + self._fifo: list[str] = [] + self._next_insertion = 0 + + def save_or_touch( + self, + *, + api_id: str, + schema_hash: str, + question: str, + recipe: dict[str, Any], + tool_name: str, + ) -> str: + fingerprint = recipe_fingerprint(api_id=api_id, schema_hash=schema_hash, recipe=recipe) + now = time.time() + + with self._lock: + existing_id = self._by_fingerprint.get(fingerprint) + if existing_id and existing_id in self._records: + rec = self._records[existing_id] + rec.question = question + rec.question_sig = _normalize_question(question) + rec.question_tokens = _tokens(question) + rec.recipe = dict(recipe) + rec.tool_name = tool_name + rec.last_used_at = now + _log_recipe(f"TOUCH {existing_id} fingerprint={fingerprint[:8]}") + return existing_id + + recipe_id = f"r_{uuid.uuid4().hex[:8]}" + self._next_insertion += 1 + record = RecipeRecord( + recipe_id=recipe_id, + api_id=api_id, + schema_hash=schema_hash, + question=question, + question_sig=_normalize_question(question), + question_tokens=_tokens(question), + recipe=dict(recipe), + tool_name=tool_name, + fingerprint=fingerprint, + created_at=now, + last_used_at=now, + insertion_index=self._next_insertion, + ) + self._records[recipe_id] = record + self._by_key[(api_id, schema_hash)].add(recipe_id) + self._by_fingerprint[fingerprint] = recipe_id + self._fifo.append(recipe_id) + self._evict_if_needed() + + params = list(get_recipe_tool_args(recipe).keys()) + _log_recipe(f"SAVE {recipe_id} params={params} q={question[:40]}") + return recipe_id + + def save_recipe( + self, + *, + api_id: str, + schema_hash: str, + question: str, + recipe: dict[str, Any], + tool_name: str, + ) -> str: + return self.save_or_touch( + api_id=api_id, + schema_hash=schema_hash, + question=question, + recipe=recipe, + tool_name=tool_name, + ) + + def get_recipe(self, recipe_id: str) -> dict[str, Any] | None: + with self._lock: + rec = self._records.get(recipe_id) + if not rec: + return None + rec.last_used_at = time.time() + return dict(rec.recipe) + + def get_recipe_meta(self, recipe_id: str) -> dict[str, Any] | None: + with self._lock: + rec = self._records.get(recipe_id) + if not rec: + return None + rec.last_used_at = time.time() + return _record_to_meta(rec) + + def suggest_recipes( + self, + *, + api_id: str, + schema_hash: str, + question: str, + k: int = 3, + ) -> list[dict[str, Any]]: + q_sig = _normalize_question(question) + with self._lock: + recs = self._records_for_key(api_id, schema_hash, include_disabled=False) + + scored: list[tuple[float, RecipeRecord]] = [] + for r in recs: + score = _similarity(q_sig, r.question_sig) + if score > 0: + scored.append((score, r)) + + scored.sort(key=lambda t: (t[0], t[1].last_used_at), reverse=True) + out = [_record_to_suggestion(r, score) for score, r in scored[: max(0, int(k))]] + + if out: + matches = " ".join(f"{s['recipe_id']}({s['score']:.2f})" for s in out) + _log_recipe(f"SUGGEST found={len(out)} [{matches}]") + return out + + def list_recipes( + self, + *, + api_id: str, + schema_hash: str, + include_disabled: bool = False, + ) -> list[dict[str, Any]]: + with self._lock: + recs = self._records_for_key(api_id, schema_hash, include_disabled=include_disabled) + recs.sort(key=lambda r: r.last_used_at, reverse=True) + return [_record_to_public(r) for r in recs] + + def find_recipe_by_tool_slug( + self, + *, + api_id: str, + schema_hash: str, + tool_slug: str, + max_slug_len: int | None = None, + ) -> dict[str, Any] | None: + with self._lock: + recs = self._records_for_key(api_id, schema_hash, include_disabled=False) + + matches = [] + for rec in recs: + slug = sanitize_tool_name(rec.tool_name) + if isinstance(max_slug_len, int) and max_slug_len > 0: + slug = slug[:max_slug_len] + if slug == tool_slug: + matches.append(rec) + + if not matches: + return None + matches.sort(key=lambda r: (r.last_used_at, r.created_at), reverse=True) + return _record_to_public(matches[0]) + + def get_downstream_description(self, *, api_id: str, schema_hash: str) -> str | None: + key = (api_id, schema_hash) + with self._lock: + cached = self._downstream_descriptions.get(key) + if not cached: + return None + description, expires_at = cached + if expires_at is not None and expires_at <= time.time(): + self._downstream_descriptions.pop(key, None) + return None + return description + + def save_downstream_description( + self, *, api_id: str, schema_hash: str, description: str, ttl_seconds: int | None = None + ) -> None: + expires_at = time.time() + ttl_seconds if ttl_seconds is not None else None + with self._lock: + self._downstream_descriptions[(api_id, schema_hash)] = (description, expires_at) + + def disable_recipe(self, recipe_id: str) -> bool: + with self._lock: + rec = self._records.get(recipe_id) + if not rec: + return False + rec.enabled = False + rec.last_used_at = time.time() + return True + + def delete_recipe(self, recipe_id: str) -> bool: + with self._lock: + return self._delete(recipe_id) + + def merge_recipes(self, source_id: str, target_id: str) -> bool: + if source_id == target_id: + return False + with self._lock: + if source_id not in self._records or target_id not in self._records: + return False + self._records[target_id].last_used_at = time.time() + return self._delete(source_id) + + def _records_for_key( + self, api_id: str, schema_hash: str, *, include_disabled: bool + ) -> list[RecipeRecord]: + ids = list(self._by_key.get((api_id, schema_hash), set())) + return [ + self._records[i] + for i in ids + if i in self._records and (include_disabled or self._records[i].enabled) + ] + + def _evict_if_needed(self) -> None: + while len(self._records) > self._max_size and self._fifo: + oldest_id = self._fifo.pop(0) + self._delete(oldest_id) + + def _delete(self, recipe_id: str) -> bool: + rec = self._records.pop(recipe_id, None) + if not rec: + return False + self._by_fingerprint.pop(rec.fingerprint, None) + self._fifo = [rid for rid in self._fifo if rid != recipe_id] + key = (rec.api_id, rec.schema_hash) + ids = self._by_key.get(key) + if ids: + ids.discard(recipe_id) + if not ids: + self._by_key.pop(key, None) + return True + + +class RedisApiAgentStore: + """Redis-backed API Agent store with the same contract as MemoryApiAgentStore.""" + + def __init__(self, url: str, *, namespace: str = "api-agent", max_size: int = 1000) -> None: + if not url: + raise ValueError("Redis API Agent store requires [redis].url") + import redis + + self._client = redis.Redis.from_url(url, decode_responses=True) + self._namespace = namespace + self._max_size = max(1, int(max_size)) + + def save_or_touch( + self, + *, + api_id: str, + schema_hash: str, + question: str, + recipe: dict[str, Any], + tool_name: str, + ) -> str: + fingerprint = recipe_fingerprint(api_id=api_id, schema_hash=schema_hash, recipe=recipe) + now = time.time() + existing_id = cast(str | None, self._client.get(self._fingerprint_key(fingerprint))) + if existing_id: + rec = self._load_record(existing_id) + if rec: + rec.question = question + rec.question_sig = _normalize_question(question) + rec.question_tokens = _tokens(question) + rec.recipe = dict(recipe) + rec.tool_name = tool_name + rec.last_used_at = now + self._save_record(rec) + return str(existing_id) + + recipe_id = f"r_{uuid.uuid4().hex[:8]}" + insertion_index = int(self._client.incr(self._key("next_insertion"))) + rec = RecipeRecord( + recipe_id=recipe_id, + api_id=api_id, + schema_hash=schema_hash, + question=question, + question_sig=_normalize_question(question), + question_tokens=_tokens(question), + recipe=dict(recipe), + tool_name=tool_name, + fingerprint=fingerprint, + created_at=now, + last_used_at=now, + insertion_index=insertion_index, + ) + pipe = self._client.pipeline() + pipe.set(self._record_key(recipe_id), json.dumps(_record_to_json(rec), sort_keys=True)) + pipe.set(self._fingerprint_key(fingerprint), recipe_id) + pipe.sadd(self._all_key(), recipe_id) + pipe.sadd(self._index_key(api_id, schema_hash), recipe_id) + pipe.rpush(self._fifo_key(), recipe_id) + pipe.execute() + self._evict_if_needed() + return recipe_id + + def save_recipe( + self, + *, + api_id: str, + schema_hash: str, + question: str, + recipe: dict[str, Any], + tool_name: str, + ) -> str: + return self.save_or_touch( + api_id=api_id, + schema_hash=schema_hash, + question=question, + recipe=recipe, + tool_name=tool_name, + ) + + def get_recipe(self, recipe_id: str) -> dict[str, Any] | None: + rec = self._load_record(recipe_id) + if not rec: + return None + rec.last_used_at = time.time() + self._save_record(rec) + return dict(rec.recipe) + + def get_recipe_meta(self, recipe_id: str) -> dict[str, Any] | None: + rec = self._load_record(recipe_id) + if not rec: + return None + rec.last_used_at = time.time() + self._save_record(rec) + return _record_to_meta(rec) + + def suggest_recipes( + self, + *, + api_id: str, + schema_hash: str, + question: str, + k: int = 3, + ) -> list[dict[str, Any]]: + q_sig = _normalize_question(question) + scored: list[tuple[float, RecipeRecord]] = [] + for rec in self._records_for_key(api_id, schema_hash, include_disabled=False): + score = _similarity(q_sig, rec.question_sig) + if score > 0: + scored.append((score, rec)) + scored.sort(key=lambda t: (t[0], t[1].last_used_at), reverse=True) + return [_record_to_suggestion(r, score) for score, r in scored[: max(0, int(k))]] + + def list_recipes( + self, + *, + api_id: str, + schema_hash: str, + include_disabled: bool = False, + ) -> list[dict[str, Any]]: + recs = self._records_for_key(api_id, schema_hash, include_disabled=include_disabled) + recs.sort(key=lambda r: r.last_used_at, reverse=True) + return [_record_to_public(r) for r in recs] + + def find_recipe_by_tool_slug( + self, + *, + api_id: str, + schema_hash: str, + tool_slug: str, + max_slug_len: int | None = None, + ) -> dict[str, Any] | None: + matches = [] + for rec in self._records_for_key(api_id, schema_hash, include_disabled=False): + slug = sanitize_tool_name(rec.tool_name) + if isinstance(max_slug_len, int) and max_slug_len > 0: + slug = slug[:max_slug_len] + if slug == tool_slug: + matches.append(rec) + if not matches: + return None + matches.sort(key=lambda r: (r.last_used_at, r.created_at), reverse=True) + return _record_to_public(matches[0]) + + def get_downstream_description(self, *, api_id: str, schema_hash: str) -> str | None: + return cast( + str | None, + self._client.get(self._downstream_description_key(api_id, schema_hash)), + ) + + def save_downstream_description( + self, *, api_id: str, schema_hash: str, description: str, ttl_seconds: int | None = None + ) -> None: + key = self._downstream_description_key(api_id, schema_hash) + if ttl_seconds is None: + self._client.set(key, description) + else: + self._client.set(key, description, ex=ttl_seconds) + + def disable_recipe(self, recipe_id: str) -> bool: + rec = self._load_record(recipe_id) + if not rec: + return False + rec.enabled = False + rec.last_used_at = time.time() + self._save_record(rec) + return True + + def delete_recipe(self, recipe_id: str) -> bool: + rec = self._load_record(recipe_id) + if not rec: + return False + pipe = self._client.pipeline() + pipe.delete(self._record_key(recipe_id)) + pipe.delete(self._fingerprint_key(rec.fingerprint)) + pipe.srem(self._all_key(), recipe_id) + pipe.srem(self._index_key(rec.api_id, rec.schema_hash), recipe_id) + pipe.lrem(self._fifo_key(), 0, recipe_id) + pipe.execute() + return True + + def merge_recipes(self, source_id: str, target_id: str) -> bool: + if source_id == target_id: + return False + target = self._load_record(target_id) + if not target or not self._load_record(source_id): + return False + target.last_used_at = time.time() + self._save_record(target) + return self.delete_recipe(source_id) + + def _records_for_key( + self, api_id: str, schema_hash: str, *, include_disabled: bool + ) -> list[RecipeRecord]: + ids = list( + cast(set[str], self._client.smembers(self._index_key(api_id, schema_hash)) or set()) + ) + recs = [] + for recipe_id in ids: + rec = self._load_record(str(recipe_id)) + if rec and (include_disabled or rec.enabled): + recs.append(rec) + return recs + + def _evict_if_needed(self) -> None: + while int(cast(int, self._client.scard(self._all_key()) or 0)) > self._max_size: + oldest_id = cast(str | None, self._client.lpop(self._fifo_key())) + if not oldest_id: + return + self.delete_recipe(str(oldest_id)) + + def _load_record(self, recipe_id: str) -> RecipeRecord | None: + raw = cast(str | None, self._client.get(self._record_key(recipe_id))) + if not raw: + return None + try: + return _record_from_json(json.loads(raw)) + except Exception: + logger.exception("Invalid recipe record in Redis: %s", recipe_id) + return None + + def _save_record(self, rec: RecipeRecord) -> None: + self._client.set(self._record_key(rec.recipe_id), json.dumps(_record_to_json(rec))) + + def _key(self, suffix: str) -> str: + return f"{self._namespace}:{suffix}" + + def _record_key(self, recipe_id: str) -> str: + return self._key(f"recipe:{recipe_id}") + + def _fingerprint_key(self, fingerprint: str) -> str: + return self._key(f"recipe_fingerprint:{fingerprint}") + + def _index_key(self, api_id: str, schema_hash: str) -> str: + digest = sha256_hex(api_id) + return self._key(f"recipes:{digest}:{schema_hash}") + + def _downstream_description_key(self, api_id: str, schema_hash: str) -> str: + digest = sha256_hex(api_id) + return self._key(f"downstream_description:{digest}:{schema_hash}") + + def _all_key(self) -> str: + return self._key("recipes:all") + + def _fifo_key(self) -> str: + return self._key("recipes:fifo") + + +class AsyncApiAgentStore: + """Async request-path adapter for synchronous store implementations.""" + + def __init__(self, store: ApiAgentStoreProtocol) -> None: + self._store = store + + async def save_or_touch( + self, + *, + api_id: str, + schema_hash: str, + question: str, + recipe: dict[str, Any], + tool_name: str, + ) -> str: + return await self._call( + self._store.save_or_touch, + api_id=api_id, + schema_hash=schema_hash, + question=question, + recipe=recipe, + tool_name=tool_name, + ) + + async def save_recipe( + self, + *, + api_id: str, + schema_hash: str, + question: str, + recipe: dict[str, Any], + tool_name: str, + ) -> str: + return await self._call( + self._store.save_recipe, + api_id=api_id, + schema_hash=schema_hash, + question=question, + recipe=recipe, + tool_name=tool_name, + ) + + async def get_recipe(self, recipe_id: str) -> dict[str, Any] | None: + return await self._call(self._store.get_recipe, recipe_id) + + async def get_recipe_meta(self, recipe_id: str) -> dict[str, Any] | None: + return await self._call(self._store.get_recipe_meta, recipe_id) + + async def list_recipes( + self, + *, + api_id: str, + schema_hash: str, + include_disabled: bool = False, + ) -> list[dict[str, Any]]: + return await self._call( + self._store.list_recipes, + api_id=api_id, + schema_hash=schema_hash, + include_disabled=include_disabled, + ) + + async def suggest_recipes( + self, + *, + api_id: str, + schema_hash: str, + question: str, + k: int = 3, + ) -> list[dict[str, Any]]: + return await self._call( + self._store.suggest_recipes, + api_id=api_id, + schema_hash=schema_hash, + question=question, + k=k, + ) + + async def find_recipe_by_tool_slug( + self, + *, + api_id: str, + schema_hash: str, + tool_slug: str, + max_slug_len: int | None = None, + ) -> dict[str, Any] | None: + return await self._call( + self._store.find_recipe_by_tool_slug, + api_id=api_id, + schema_hash=schema_hash, + tool_slug=tool_slug, + max_slug_len=max_slug_len, + ) + + async def get_downstream_description(self, *, api_id: str, schema_hash: str) -> str | None: + return await self._call( + self._store.get_downstream_description, + api_id=api_id, + schema_hash=schema_hash, + ) + + async def save_downstream_description( + self, *, api_id: str, schema_hash: str, description: str, ttl_seconds: int | None = None + ) -> None: + await self._call( + self._store.save_downstream_description, + api_id=api_id, + schema_hash=schema_hash, + description=description, + ttl_seconds=ttl_seconds, + ) + + async def disable_recipe(self, recipe_id: str) -> bool: + return await self._call(self._store.disable_recipe, recipe_id) + + async def delete_recipe(self, recipe_id: str) -> bool: + return await self._call(self._store.delete_recipe, recipe_id) + + async def merge_recipes(self, source_id: str, target_id: str) -> bool: + return await self._call(self._store.merge_recipes, source_id, target_id) + + async def _call( + self, + method: Callable[..., StoreResult], + *args: Any, + **kwargs: Any, + ) -> StoreResult: + if isinstance(self._store, RedisApiAgentStore): + return await asyncio.to_thread(partial(method, *args, **kwargs)) + return method(*args, **kwargs) + + +def _record_to_json(rec: RecipeRecord) -> dict[str, Any]: + data = asdict(rec) + data["question_tokens"] = sorted(rec.question_tokens) + return data + + +def _record_from_json(data: dict[str, Any]) -> RecipeRecord: + return RecipeRecord( + recipe_id=str(data["recipe_id"]), + api_id=str(data["api_id"]), + schema_hash=str(data["schema_hash"]), + question=str(data["question"]), + question_sig=str(data.get("question_sig") or _normalize_question(data["question"])), + question_tokens=set(data.get("question_tokens") or _tokens(str(data["question"]))), + recipe=dict(data.get("recipe") or {}), + tool_name=str(data.get("tool_name") or "recipe"), + fingerprint=str(data["fingerprint"]), + created_at=float(data.get("created_at") or time.time()), + last_used_at=float(data.get("last_used_at") or time.time()), + insertion_index=int(data.get("insertion_index") or 0), + enabled=bool(data.get("enabled", True)), + ) + + +def _record_to_meta(rec: RecipeRecord) -> dict[str, Any]: + return { + "recipe_id": rec.recipe_id, + "api_id": rec.api_id, + "schema_hash": rec.schema_hash, + "question": rec.question, + "tool_name": rec.tool_name, + "description": get_recipe_description(rec.recipe), + "recipe": dict(rec.recipe), + "fingerprint": rec.fingerprint, + "enabled": rec.enabled, + "created_at": rec.created_at, + "last_used_at": rec.last_used_at, + } + + +def _record_to_suggestion(rec: RecipeRecord, score: float) -> dict[str, Any]: + return { + "recipe_id": rec.recipe_id, + "score": round(score, 4), + "created_at": rec.created_at, + "last_used_at": rec.last_used_at, + "question": rec.question, + "tool_name": rec.tool_name, + "description": get_recipe_description(rec.recipe), + } + + +def _record_to_public(rec: RecipeRecord) -> dict[str, Any]: + tool_args = get_recipe_tool_args(rec.recipe) + steps = get_recipe_steps(rec.recipe) + return { + "recipe_id": rec.recipe_id, + "question": rec.question, + "tool_name": get_recipe_tool_name(rec.recipe) or rec.tool_name, + "description": get_recipe_description(rec.recipe), + "created_at": rec.created_at, + "last_used_at": rec.last_used_at, + "fingerprint": rec.fingerprint, + "enabled": rec.enabled, + "public_contract": dict(rec.recipe.get("public_contract", {})), + "execution_plan": dict(rec.recipe.get("execution_plan", {})), + "tool_args": dict(tool_args), + "steps": list(steps), + } + + +def create_api_agent_store() -> ApiAgentStoreProtocol: + if settings.STORAGE_BACKEND.lower() == "redis": + return RedisApiAgentStore( + settings.REDIS_URL, + namespace=settings.STORAGE_NAMESPACE, + max_size=settings.RECIPE_CACHE_SIZE, + ) + return MemoryApiAgentStore(max_size=settings.RECIPE_CACHE_SIZE) + + +API_AGENT_STORE = create_api_agent_store() +ASYNC_API_AGENT_STORE = AsyncApiAgentStore(API_AGENT_STORE) diff --git a/api_agent/tools/__init__.py b/api_agent/tools/__init__.py index 33a9f21..a4c2c7f 100644 --- a/api_agent/tools/__init__.py +++ b/api_agent/tools/__init__.py @@ -4,19 +4,19 @@ from fastmcp import FastMCP -from .execute import register_execute_tool from .query import register_query_tool logger = logging.getLogger(__name__) -def register_all_tools(mcp: FastMCP) -> None: - """Register all tools with generic internal names. +def register_public_tools(mcp: FastMCP) -> None: + """Register public tools with generic internal names. - Internal names (_query, _execute) are transformed by middleware - to session-specific names (e.g., flights_query, catalog_execute). + Internal names are transformed by middleware to session-specific names. """ register_query_tool(mcp) - register_execute_tool(mcp) - logger.info("Registered tools: _query, _execute (dynamically named per session)") + logger.info("Registered public tools: _query") + + +register_all_tools = register_public_tools diff --git a/api_agent/tools/execute.py b/api_agent/tools/execute.py deleted file mode 100644 index 1533bb4..0000000 --- a/api_agent/tools/execute.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Unified MCP tool for direct API execution.""" - -import json -from typing import Annotated, Any - -from fastmcp import FastMCP -from pydantic import Field - -from ..config import settings -from ..context import MissingHeaderError, get_request_context -from ..graphql import execute_query -from ..rest.client import execute_request -from ..rest.schema_loader import fetch_schema_context - - -def register_execute_tool(mcp: FastMCP) -> None: - """Register the unified execute tool with generic internal name.""" - - @mcp.tool( - name="_execute", - description="""Execute a specific API call directly. - -For GraphQL: provide query (and optional variables) -For REST: provide method and path (and optional params/body) - -Use this to re-run queries from the query tool or execute known operations.""", - tags={"execute"}, - ) - async def execute( - # GraphQL params - query: Annotated[str | None, Field(description="GraphQL query string")] = None, - variables: Annotated[dict[str, Any] | None, Field(description="GraphQL variables")] = None, - # REST params - method: Annotated[str | None, Field(description="HTTP method (GET, POST, etc.)")] = None, - path: Annotated[str | None, Field(description="API path (e.g., /users/{id})")] = None, - path_params: Annotated[ - dict[str, Any] | None, Field(description="Path parameter values") - ] = None, - query_params: Annotated[ - dict[str, Any] | None, Field(description="Query string parameters") - ] = None, - body: Annotated[ - dict[str, Any] | None, Field(description="Request body (for POST/PUT/PATCH)") - ] = None, - ) -> dict: - """Execute API call directly.""" - try: - ctx = get_request_context() - except MissingHeaderError as e: - return {"ok": False, "error": str(e)} - - if ctx.api_type == "graphql": - # GraphQL execution - if not query: - return {"ok": False, "error": "query param required for GraphQL"} - - result = await execute_query(query, variables, ctx.target_url, ctx.target_headers) - - if not result.get("success"): - return {"ok": False, "error": result.get("error", "Query failed")} - - data = result.get("data", {}) - data_str = json.dumps(data, indent=2) - - if len(data_str) > settings.MAX_RESPONSE_CHARS: - return { - "ok": True, - "data": f"{data_str[: settings.MAX_RESPONSE_CHARS]}\n\n[TRUNCATED - Use pagination to fetch smaller chunks.]", - } - - return {"ok": True, "data": data} - - else: - # REST execution - if not method or not path: - return {"ok": False, "error": "method and path params required for REST"} - - # Get base URL from header override or spec - base_url = ctx.base_url - if not base_url: - _, base_url, _ = await fetch_schema_context(ctx.target_url, ctx.target_headers) - if not base_url: - return {"ok": False, "error": "Could not extract base URL from OpenAPI spec"} - - result = await execute_request( - method, - path, - path_params, - query_params, - body, - base_url=base_url, - headers=ctx.target_headers, - allow_unsafe_paths=list(ctx.allow_unsafe_paths), - ) - - if not result.get("success"): - return {"ok": False, "error": result.get("error", "Request failed")} - - data = result.get("data", {}) - data_str = json.dumps(data, indent=2) if isinstance(data, (dict, list)) else str(data) - - if len(data_str) > settings.MAX_RESPONSE_CHARS: - return { - "ok": True, - "data": f"{data_str[: settings.MAX_RESPONSE_CHARS]}\n\n[TRUNCATED - Use query params to limit results.]", - } - - return {"ok": True, "data": data} diff --git a/api_agent/tools/query.py b/api_agent/tools/query.py index f237f7e..79ce08d 100644 --- a/api_agent/tools/query.py +++ b/api_agent/tools/query.py @@ -9,21 +9,23 @@ from ..agent.graphql_agent import process_query from ..agent.rest_agent import process_rest_query from ..context import MissingHeaderError, get_request_context -from ..recipe import consume_recipe_changes, reset_recipe_change_flag +from ..query_response import QueryResponse +from ..recipe.state import consume_recipe_changes, reset_recipe_change_flag from ..utils.csv import to_csv -def _build_response(result: dict, calls_key: str, ctx) -> dict: - """Build unified response dict from agent result.""" - response = { - "ok": result.get("ok", False), - "data": result.get("data"), - calls_key: result.get(calls_key, []), - "error": result.get("error"), - } - if ctx.include_result or result.get("result") is not None: - response["result"] = result.get("result") - return response +def _should_include_result(response: QueryResponse, req_ctx) -> bool: + """Include rows when explicitly requested or when debug wraps direct CSV output.""" + return bool(req_ctx.include_result or (req_ctx.debug and response.should_return_csv)) + + +def _should_return_csv(response: QueryResponse, req_ctx, *, return_directly: bool) -> bool: + """Return raw CSV when requested and rows are available, unless debug wraps output.""" + return bool( + response.result is not None + and not req_ctx.debug + and (return_directly or response.should_return_csv) + ) def register_query_tool(mcp: FastMCP) -> None: @@ -31,15 +33,20 @@ def register_query_tool(mcp: FastMCP) -> None: @mcp.tool( name="_query", - description="""Ask questions about the API in natural language. + title="Ask API", + description="""Ask a natural-language question about the configured API. -The agent reads the schema, builds queries, executes them, and can do multi-step data processing. - -Returns answer and the queries/calls made (reusable with execute tool).""", +Use when the client needs fresh API data, joins, filtering, ranking, or SQL-style post-processing. +The agent reads the API schema, calls the target API, and returns the answer plus calls made.""", tags={"query", "nl"}, + annotations={"title": "Ask API", "openWorldHint": True}, ) async def query( question: Annotated[str, Field(description="Natural language question about the API")], + return_directly: Annotated[ + bool, + Field(description="Return raw CSV directly when tabular result rows are available."), + ] = False, ctx: Context | None = None, ) -> dict | str: """Process natural language query against configured API.""" @@ -59,15 +66,19 @@ async def query( # Notify clients if recipes changed if ctx and consume_recipe_changes(): try: - await ctx.send_tool_list_changed() + notify = getattr(ctx, "send_tool_list_changed", None) + if notify: + await notify() except Exception: pass # Direct return: just CSV, no wrapper - if result.get("result") is not None and result.get("data") is None: - return to_csv(result["result"]) - calls_key = "queries" if req_ctx.api_type == "graphql" else "api_calls" - response = _build_response(result, calls_key, req_ctx) - - return response + response = QueryResponse.from_agent_result(result, calls_key) + if _should_return_csv(response, req_ctx, return_directly=return_directly): + return to_csv(response.result) + + return response.to_mcp_payload( + include_result=_should_include_result(response, req_ctx), + include_debug=req_ctx.debug, + ) diff --git a/api_agent/tracing.py b/api_agent/tracing.py index 3ce450c..1bbc7a5 100644 --- a/api_agent/tracing.py +++ b/api_agent/tracing.py @@ -1,66 +1,42 @@ """OpenTelemetry tracing setup.""" import logging -import os from contextlib import contextmanager from typing import Any, Generator -from .config import settings +from openinference.instrumentation import using_metadata +from openinference.semconv.trace import OpenInferenceSpanKindValues, SpanAttributes +from opentelemetry import trace logger = logging.getLogger(__name__) -_tracer_ready = False -_using_metadata_fn = None +tracer = trace.get_tracer(__name__) -def init_tracing() -> None: - """Initialize tracing if OTLP endpoint available.""" - global _tracer_ready, _using_metadata_fn +def span_trace_id(span: Any) -> str | None: + if not span: + return None + trace_id = span.get_span_context().trace_id + if trace_id == trace.INVALID_TRACE_ID: + return None + return trace.format_trace_id(trace_id) - otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") - if not otlp_endpoint: - return - try: - from openinference.instrumentation import using_metadata - from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor - from opentelemetry import trace - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter - from opentelemetry.sdk.resources import Resource - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - - otlp_endpoint = otlp_endpoint.rstrip("/") - resource = Resource.create({"service.name": settings.SERVICE_NAME}) - provider = TracerProvider(resource=resource) - provider.add_span_processor( - BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{otlp_endpoint}/v1/traces")) - ) - trace.set_tracer_provider(provider) - OpenAIAgentsInstrumentor().instrument(tracer_provider=provider) - - _using_metadata_fn = using_metadata - _tracer_ready = True - logger.info(f"Tracing enabled: {otlp_endpoint}") - except Exception as e: - logger.warning(f"Failed to setup tracing: {e}") +def agent_span_attributes(mcp_name: str, agent_type: str) -> dict[str, str]: + return { + "mcp_name": mcp_name, + "agent_type": agent_type, + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.AGENT.value, + } @contextmanager def trace_metadata(metadata: dict[str, Any]) -> Generator[None, None, None]: """Context manager for span metadata. No-op if tracing disabled.""" - if _tracer_ready and _using_metadata_fn: - with _using_metadata_fn(metadata): - yield - else: + with using_metadata(metadata): yield -def is_enabled() -> bool: - """Check if tracing is enabled.""" - return _tracer_ready - - @contextmanager def trace_span(name: str, attributes: dict[str, Any] | None = None) -> Generator[Any, None, None]: """Create a span with optional attributes. No-op if tracing disabled. @@ -72,19 +48,12 @@ def trace_span(name: str, attributes: dict[str, Any] | None = None) -> Generator Yields: Span object (or None if tracing disabled) """ - if not _tracer_ready: - yield None - return - try: - from opentelemetry import trace - - tracer = trace.get_tracer(__name__) with tracer.start_as_current_span(name) as span: if attributes and span: for key, value in attributes.items(): span.set_attribute(key, value) yield span - except Exception as e: - logger.warning(f"Failed to create span: {e}") + except Exception: + logger.warning("Failed to create span", exc_info=True) yield None diff --git a/api_agent/utils/http_errors.py b/api_agent/utils/http_errors.py deleted file mode 100644 index 4b3d4ed..0000000 --- a/api_agent/utils/http_errors.py +++ /dev/null @@ -1,47 +0,0 @@ -"""HTTP error detail extraction utilities.""" - -from typing import Any - -import httpx - - -def build_http_error_response(e: httpx.HTTPStatusError) -> dict[str, Any]: - """Build a consistent error payload from HTTPStatusError.""" - status_code = e.response.status_code if e.response is not None else 0 - out: dict[str, Any] = { - "success": False, - "error": f"HTTP {status_code}", - "status_code": status_code, - } - details = extract_http_error_details(e.response) - if details is not None: - out["details"] = details - return out - - -def extract_http_error_details(response: httpx.Response | None) -> Any | None: - """Extract useful error payload from non-2xx HTTP responses.""" - if response is None: - return None - - try: - payload = response.json() - except Exception: - payload = None - - if payload is not None: - if isinstance(payload, dict): - if "errors" in payload: - return payload["errors"] - if "error" in payload: - return payload["error"] - if "message" in payload: - return payload["message"] - return payload - - # Bound fallback text extraction to avoid loading huge payloads. - raw = response.content[:1500] if response.content else b"" - text = raw.decode("utf-8", errors="replace").strip() if raw else "" - if text: - return text[:1000] - return None diff --git a/pyproject.toml b/pyproject.toml index 2e8c4d0..4f40b51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,18 +7,23 @@ license = { text = "MIT" } authors = [{ name = "API Agent Contributors" }] requires-python = ">=3.11" dependencies = [ - "arize-otel>=0.11.0", - "duckdb>=1.0.0", - "fastmcp>=2.13.3", + "duckdb>=1.5.3", + "fastmcp>=3.3.1,<4", "httpx>=0.28.1", - "openai-agents>=0.0.3", - "openinference-instrumentation-openai-agents>=1.4.0", - "pydantic>=2.12.5", - "pydantic-settings>=2.12.0", - "pyyaml>=6.0", - "rapidfuzz>=3.0.0", - "starlette>=0.50.0", - "uvicorn>=0.38.0", + "openai-agents>=0.17.4", + "openinference-instrumentation-openai-agents>=1.5.1", + "opentelemetry-api==1.41.0", + "opentelemetry-distro[otlp]==0.62b0", + "opentelemetry-instrumentation==0.62b0", + "opentelemetry-sdk==1.41.0", + "opentelemetry-semantic-conventions==0.62b0", + "pydantic>=2.13.4", + "pydantic-settings>=2.14.1", + "pyyaml>=6.0.3", + "rapidfuzz>=3.14.5", + "redis>=8.0.0", + "starlette>=1.2.0", + "uvicorn>=0.48.0", ] [project.scripts] @@ -41,8 +46,8 @@ ignore = ["E501", "E402"] [dependency-groups] dev = [ - "pytest>=9.0.2", - "pytest-asyncio>=1.3.0", - "ruff>=0.8.0", - "ty>=0.0.15", + "pytest>=9.0.3", + "pytest-asyncio>=1.4.0", + "ruff>=0.15.15", + "ty>=0.0.40", ] diff --git a/start.sh b/start.sh index 153b67c..e421460 100644 --- a/start.sh +++ b/start.sh @@ -1,5 +1,9 @@ #!/bin/sh -[ -n "${PORT:-}" ] || PORT=3000 -[ -n "${HOST:-}" ] || HOST=0.0.0.0 -export PORT HOST -exec uv run python -m api_agent +[ -n "${API_AGENT_CONFIG:-}" ] || API_AGENT_CONFIG=/app/api-agent.toml +export API_AGENT_CONFIG + +if [ -n "${OTEL_EXPORTER_OTLP_ENDPOINT:-}" ] || [ -n "${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}" ]; then + exec uv run --no-sync opentelemetry-instrument api-agent +fi + +exec uv run --no-sync api-agent diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3fb4ac5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +"""Shared pytest setup.""" + +import os + +os.environ.setdefault("OPENAI_API_KEY", "test") diff --git a/tests/test_agent_queries.py b/tests/test_agent_queries.py new file mode 100644 index 0000000..3c38d4d --- /dev/null +++ b/tests/test_agent_queries.py @@ -0,0 +1,103 @@ +"""Behavior tests for public agent query entrypoints.""" + +from types import SimpleNamespace +from typing import Literal + +import pytest +from agents import ModelRefusalError + +from api_agent.agent.graphql_agent import process_query +from api_agent.agent.rest_agent import process_rest_query +from api_agent.context import RequestContext + + +def _request_context( + api_type: Literal["graphql", "rest"], *, base_url: str | None = None +) -> RequestContext: + return RequestContext( + target_url="https://api.example.com/schema", + api_type=api_type, + target_headers={}, + allow_unsafe_paths=(), + base_url=base_url, + include_result=False, + poll_paths=(), + ) + + +@pytest.mark.asyncio +async def test_graphql_query_returns_agent_answer(monkeypatch): + seen_query = "" + + async def fetch_schema(_query, _variables, _endpoint, _headers): + return {"success": False, "error": "schema unavailable"} + + async def run_agent(_agent, query, max_turns, run_config): + nonlocal seen_query + seen_query = query + return SimpleNamespace(final_output="answer") + + monkeypatch.setattr("api_agent.agent.graphql_agent.graphql_fetch", fetch_schema) + monkeypatch.setattr("api_agent.agent.runtime.Runner.run", run_agent) + monkeypatch.setattr("api_agent.agent.runtime.settings.ENABLE_RECIPES", False) + + result = await process_query("list users", _request_context("graphql")) + + assert seen_query == "list users" + assert result == { + "ok": True, + "data": "answer", + "result": None, + "queries": [], + "error": None, + } + + +@pytest.mark.asyncio +async def test_rest_query_uses_loaded_schema_and_returns_agent_answer(monkeypatch): + seen_query = "" + + async def fetch_schema_context(_target_url, _headers): + return "\nGET /users", "https://api.example.com", '{"openapi":"3.0.0"}' + + async def run_agent(_agent, query, max_turns, run_config): + nonlocal seen_query + seen_query = query + return SimpleNamespace(final_output="answer") + + monkeypatch.setattr("api_agent.agent.rest_agent.fetch_schema_context", fetch_schema_context) + monkeypatch.setattr("api_agent.agent.runtime.Runner.run", run_agent) + monkeypatch.setattr("api_agent.agent.runtime.settings.ENABLE_RECIPES", False) + + result = await process_rest_query("list users", _request_context("rest")) + + assert seen_query == "\nGET /users\n\nQuestion: list users" + assert result == { + "ok": True, + "data": "answer", + "result": None, + "api_calls": [], + "error": None, + } + + +@pytest.mark.asyncio +async def test_rest_query_returns_clean_model_refusal(monkeypatch): + async def fetch_schema_context(_target_url, _headers): + return "\nGET /users", "https://api.example.com", '{"openapi":"3.0.0"}' + + async def run_agent(_agent, _query, max_turns, run_config): + raise ModelRefusalError("cannot comply") + + monkeypatch.setattr("api_agent.agent.rest_agent.fetch_schema_context", fetch_schema_context) + monkeypatch.setattr("api_agent.agent.runtime.Runner.run", run_agent) + monkeypatch.setattr("api_agent.agent.runtime.settings.ENABLE_RECIPES", False) + + result = await process_rest_query("list users", _request_context("rest")) + + assert result == { + "ok": False, + "data": None, + "api_calls": [], + "error": "Model refused: cannot comply", + } diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py new file mode 100644 index 0000000..d133bf8 --- /dev/null +++ b/tests/test_agent_runtime.py @@ -0,0 +1,102 @@ +from contextvars import ContextVar +from types import SimpleNamespace + +import pytest + +from api_agent.agent.runtime import ( + AgentRunResult, + AgentRuntimeConfig, + AgentRuntimeState, + LoadedSchema, + _load_recipe_context, + run_agent_query, +) +from api_agent.context import RequestContext + + +def _request_context() -> RequestContext: + return RequestContext( + target_url="https://spec", + api_type="rest", + target_headers={}, + allow_unsafe_paths=(), + base_url="https://api", + include_result=False, + poll_paths=(), + ) + + +def _runtime_config(load_schema, log=lambda _msg: None) -> AgentRuntimeConfig: + return AgentRuntimeConfig( + agent_name="test", + agent_type="rest", + call_key="calls", + calls_var=ContextVar("calls"), + recipe_steps_var=ContextVar("recipe_steps"), + query_results_var=ContextVar("query_results"), + last_result_var=ContextVar("last_result"), + raw_schema_var=ContextVar("raw_schema"), + load_schema=load_schema, + build_tools=lambda _ctx, _state: [], + build_prompt=lambda _ctx, _state: "", + build_api_id=lambda _ctx, _state: "rest:https://spec|https://api", + log=log, + done_log_label="calls", + exception_message="failed", + ) + + +@pytest.mark.asyncio +async def test_load_recipe_context_continues_when_lookup_fails(monkeypatch): + monkeypatch.setattr("api_agent.agent.runtime.settings.ENABLE_RECIPES", True) + + async def load_schema(_ctx): + return LoadedSchema(schema_context="") + + async def fail_search(*_args, **_kwargs): + raise RuntimeError("redis down") + + monkeypatch.setattr("api_agent.agent.runtime.search_recipes", fail_search) + + logs = [] + state = AgentRuntimeState(schema_context="schema", raw_schema='{"openapi":"3.0.0"}') + + await _load_recipe_context( + "list users", _request_context(), _runtime_config(load_schema, logs.append), state + ) + + assert state.suggestions == [] + assert state.recipe_context == "" + assert logs == ["PRE-FLIGHT no matches for api_id=rest:https://spec|https://api"] + + +@pytest.mark.asyncio +async def test_run_agent_query_skips_learning_after_recipe_tool_use(monkeypatch): + async def load_schema(_ctx): + return LoadedSchema(schema_context="", raw_schema='{"openapi":"3.0.0"}') + + async def run_agent(_agent, _question, _config, _state): + return AgentRunResult( + output=SimpleNamespace(final_output="answer"), + calls=[], + last_data=[{"id": 1}], + turn_info="turn 1/10", + trace_id=None, + ) + + captured = {} + + async def maybe_extract(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr("api_agent.agent.runtime._run_agent", run_agent) + monkeypatch.setattr("api_agent.agent.runtime.maybe_extract_and_save_recipe", maybe_extract) + monkeypatch.setattr("api_agent.agent.runtime.recipe_tool_was_used", lambda: True) + monkeypatch.setattr("api_agent.agent.runtime.settings.ENABLE_RECIPES", True) + + result = await run_agent_query("list users", _request_context(), _runtime_config(load_schema)) + + assert result["ok"] is True + assert captured["skip_condition"] is True + assert captured["original_result"] == [{"id": 1}] + assert captured["validate_candidate"] is not None diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..bcc1ebb --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,46 @@ +import pytest +from starlette.routing import Route +from starlette.testclient import TestClient + +from api_agent.__main__ import create_app, create_public_mcp +from api_agent.agent.prompts import DECISION_GUIDANCE + + +def _route_paths(app) -> set[str]: + return {route.path for route in app.routes if isinstance(route, Route)} + + +@pytest.mark.asyncio +async def test_public_mcp_has_only_public_tools(): + mcp = create_public_mcp() + + tools = await mcp.list_tools(run_middleware=False) + names = {tool.name for tool in tools} + + assert names == {"_query"} + query_tool = tools[0] + assert "r_*" not in (query_tool.description or "") + assert "specific tool" not in (query_tool.description or "") + + +def test_decision_guidance_prefers_recipe_tools_first(): + assert "Check available recipe tools before direct API/SQL calls" in DECISION_GUIDANCE + + +def test_create_app_mounts_public_mcp_and_health_only(): + app = create_app() + + assert _route_paths(app) == {"/mcp", "/health"} + + +def test_mcp_path_does_not_redirect_to_slash_path(): + app = create_app() + + with TestClient(app, follow_redirects=False) as client: + response = client.get("/mcp") + slash_response = client.get("/mcp/") + + assert response.status_code == 405 + assert "location" not in response.headers + assert slash_response.status_code == 307 + assert slash_response.headers["location"] == "http://testserver/mcp" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..22cb431 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,147 @@ +from api_agent import config + + +def _write_config(tmp_path, text: str): + path = tmp_path / "api-agent.toml" + path.write_text(text.strip()) + return path + + +def test_settings_load_app_config_from_toml(tmp_path, monkeypatch): + path = _write_config( + tmp_path, + """ +[mcp] +name = "Custom API Agent" + +[server] +host = "127.0.0.1" +port = 3001 +transport = "http" +stateless_http = false +cors_allowed_origins = "https://example.com" +debug = true + +[model] +api = "chat_completions" +name = "gpt-4.1" +openai_base_url = "https://toml.example/v1" +reasoning_effort = "" + +[description] +model_name = "gpt-5.4-mini" +timeout_seconds = 1.5 + +[agent] +max_turns = 7 +max_response_chars = 12345 +max_schema_chars = 23456 +max_preview_rows = 3 +max_tool_response_chars = 34567 + +[polling] +max_polls = 4 +default_delay_ms = 500 +max_delay_ms = 1000 + +[recipes] +enabled = false +max_size = 12 +learn_rate = 0.5 + +[storage] +backend = "redis" +namespace = "test-agent" + +[redis] +url = "redis://localhost:6379/2" +""", + ) + monkeypatch.setenv("API_AGENT_CONFIG", str(path)) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("PORT", raising=False) + + settings = config.Settings() + + assert settings.MCP_NAME == "Custom API Agent" + assert settings.HOST == "127.0.0.1" + assert settings.PORT == 3001 + assert settings.TRANSPORT == "http" + assert settings.STATELESS_HTTP is False + assert settings.CORS_ALLOWED_ORIGINS == "https://example.com" + assert settings.DEBUG is True + assert settings.MODEL_API == "chat_completions" + assert settings.MODEL_NAME == "gpt-4.1" + assert settings.OPENAI_BASE_URL == "https://toml.example/v1" + assert settings.REASONING_EFFORT == "" + assert settings.DESCRIPTION_MODEL_NAME == "gpt-5.4-mini" + assert settings.DESCRIPTION_TIMEOUT_SECONDS == 1.5 + assert settings.MAX_AGENT_TURNS == 7 + assert settings.MAX_RESPONSE_CHARS == 12345 + assert settings.MAX_SCHEMA_CHARS == 23456 + assert settings.MAX_PREVIEW_ROWS == 3 + assert settings.MAX_TOOL_RESPONSE_CHARS == 34567 + assert settings.MAX_POLLS == 4 + assert settings.DEFAULT_POLL_DELAY_MS == 500 + assert settings.MAX_POLL_DELAY_MS == 1000 + assert settings.ENABLE_RECIPES is False + assert settings.STORAGE_BACKEND == "redis" + assert settings.RECIPE_CACHE_SIZE == 12 + assert settings.RECIPE_LEARN_RATE == 0.5 + assert settings.STORAGE_NAMESPACE == "test-agent" + assert settings.REDIS_URL == "redis://localhost:6379/2" + + +def test_env_does_not_override_app_settings(monkeypatch): + monkeypatch.setenv("API_AGENT_CONFIG", "/tmp/no-such-api-agent.toml") + monkeypatch.setenv("API_AGENT_MODEL_NAME", "env-model") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + settings = config.Settings() + + assert settings.MODEL_NAME == "gpt-5.5" + assert settings.DESCRIPTION_TIMEOUT_SECONDS == 15.0 + + +def test_openai_api_key_loads_from_env(monkeypatch): + monkeypatch.setenv("API_AGENT_CONFIG", "/tmp/no-such-api-agent.toml") + monkeypatch.setenv("OPENAI_API_KEY", "env-key") + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + settings = config.Settings() + + assert settings.OPENAI_API_KEY == "env-key" + + +def test_openai_base_url_env_overrides_toml(tmp_path, monkeypatch): + path = _write_config( + tmp_path, + """ +[model] +openai_base_url = "https://toml.example/v1" +""", + ) + monkeypatch.setenv("API_AGENT_CONFIG", str(path)) + monkeypatch.setenv("OPENAI_BASE_URL", "https://env.example/v1") + + settings = config.Settings() + + assert settings.OPENAI_BASE_URL == "https://env.example/v1" + + +def test_port_env_overrides_toml(tmp_path, monkeypatch): + path = _write_config( + tmp_path, + """ +[server] +port = 3001 +""", + ) + monkeypatch.setenv("API_AGENT_CONFIG", str(path)) + monkeypatch.setenv("PORT", "4321") + + settings = config.Settings() + + assert settings.PORT == 4321 diff --git a/tests/test_context.py b/tests/test_context.py index f924766..1a4e8e5 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -11,12 +11,49 @@ get_full_hostname, get_request_context, get_tool_name_prefix, + parse_request_context, ) class TestGetRequestContext: """Test header extraction and validation.""" + def test_parse_request_context_is_pure(self): + ctx = parse_request_context( + { + "x-target-url": "https://api.example.com/openapi.json", + "x-api-type": "rest", + "x-target-headers": '{"Authorization": "Bearer xxx"}', + "x-base-url": "https://api.example.com/v1", + "x-allow-unsafe-paths": '["/search"]', + "x-poll-paths": '["/jobs"]', + "x-recipe-learn-rate": "1", + "x-debug": "true", + } + ) + + assert ctx.target_url == "https://api.example.com/openapi.json" + assert ctx.api_type == "rest" + assert ctx.target_headers == {"Authorization": "Bearer xxx"} + assert ctx.base_url == "https://api.example.com/v1" + assert ctx.allow_unsafe_paths == ("/search",) + assert ctx.include_result is False + assert ctx.poll_paths == ("/jobs",) + assert ctx.learning_rate == 1.0 + assert ctx.debug is True + + def test_learning_rate_rejects_invalid_values(self): + headers = { + "x-target-url": "https://api.example.com/openapi.json", + "x-api-type": "rest", + } + + with pytest.raises(MissingHeaderError, match="X-Recipe-Learn-Rate"): + parse_request_context({**headers, "x-recipe-learn-rate": "always"}) + + with pytest.raises(MissingHeaderError, match="between 0 and 1"): + parse_request_context({**headers, "x-recipe-learn-rate": "1.2"}) + @patch("api_agent.context.get_http_headers") def test_extracts_all_headers(self, mock_headers): mock_headers.return_value = { @@ -111,6 +148,57 @@ def test_case_insensitive_headers(self, mock_headers): ctx = get_request_context() assert ctx.target_url == "https://api.example.com" + @patch("api_agent.context.get_http_headers") + def test_passthrough_headers_merged(self, mock_headers): + mock_headers.return_value = { + "x-target-url": "https://api.example.com/graphql", + "x-api-type": "graphql", + "x-target-headers": '{"X-Api-Key": "from-json"}', + "x-passthrough-headers": '["x-request-id", "x-correlation-id"]', + "x-request-id": "req-abc", + "x-correlation-id": "corr-xyz", + } + ctx = get_request_context() + assert ctx.target_headers == { + "X-Api-Key": "from-json", + "X-Request-Id": "req-abc", + "X-Correlation-Id": "corr-xyz", + } + + @patch("api_agent.context.get_http_headers") + def test_passthrough_headers_invalid_json_ignored(self, mock_headers): + mock_headers.return_value = { + "x-target-url": "https://api.example.com/graphql", + "x-api-type": "graphql", + "x-passthrough-headers": "not-json", + "x-request-id": "req-abc", + } + ctx = get_request_context() + assert ctx.target_headers == {} + + @patch("api_agent.context.get_http_headers") + def test_passthrough_headers_forwards_authorization_when_listed(self, mock_headers): + mock_headers.return_value = { + "x-target-url": "https://api.example.com/graphql", + "x-api-type": "graphql", + "x-passthrough-headers": '["authorization"]', + "authorization": "Bearer from-client", + } + ctx = get_request_context() + assert ctx.target_headers == {"Authorization": "Bearer from-client"} + mock_headers.assert_called_once_with(include={"authorization"}) + + @patch("api_agent.context.get_http_headers") + def test_passthrough_skips_missing_headers(self, mock_headers): + mock_headers.return_value = { + "x-target-url": "https://api.example.com/graphql", + "x-api-type": "graphql", + "x-passthrough-headers": '["x-missing", "x-request-id"]', + "x-request-id": "only-this", + } + ctx = get_request_context() + assert ctx.target_headers == {"X-Request-Id": "only-this"} + class TestRequestContext: """Test RequestContext dataclass.""" @@ -126,7 +214,7 @@ def test_frozen_immutable(self): poll_paths=(), ) with pytest.raises(Exception): # FrozenInstanceError - ctx.target_url = "new" # type: ignore[misc] # intentional write to frozen field + ctx.target_url = "new" # ty: ignore[invalid-assignment] # intentional write to frozen field class TestBaseUrl: @@ -246,9 +334,9 @@ def test_simple_url_skips_api(self): assert result == "example" # skips 'api' and 'com' def test_complex_subdomain(self): - url = "https://flights-api-qa.internal.example.com/openapi.json" + url = "https://flights-service.internal.example.com/openapi.json" result = get_tool_name_prefix(url) - assert result == "flights_api_qa_example" # skips internal, com + assert result == "flights_service_example" # skips internal, com def test_consistent_result(self): url = "https://api.example.com/openapi.json" @@ -258,7 +346,7 @@ def test_consistent_result(self): def test_different_urls_different_result(self): url1 = "https://api.example.com/openapi.json" - url2 = "https://api.stripe.com/openapi.json" + url2 = "https://payments.example.net/openapi.json" assert get_tool_name_prefix(url1) != get_tool_name_prefix(url2) def test_empty_url(self): @@ -277,8 +365,8 @@ class TestGetFullHostname: """Test full hostname extraction for descriptions.""" def test_extracts_hostname(self): - url = "https://flights-api-qa.example.com/openapi.json" - assert get_full_hostname(url) == "flights-api-qa.example.com" + url = "https://flights-service.example.com/openapi.json" + assert get_full_hostname(url) == "flights-service.example.com" def test_empty_url(self): assert get_full_hostname("") == "api" @@ -297,6 +385,13 @@ def test_explicit_header_takes_priority(self): } assert extract_api_name(headers) == "my_custom_api" + def test_explicit_header_preserves_hyphen(self): + headers = { + "x-api-name": "weather-alerts", + "x-target-url": "https://other-api.example.com", + } + assert extract_api_name(headers) == "weather-alerts" + def test_falls_back_to_url_prefix(self): headers = {"x-target-url": "https://flights-api.example.com/api"} result = extract_api_name(headers) diff --git a/tests/test_description.py b/tests/test_description.py new file mode 100644 index 0000000..4eae766 --- /dev/null +++ b/tests/test_description.py @@ -0,0 +1,522 @@ +import asyncio +import json + +import pytest + +from api_agent import description +from api_agent.store import AsyncApiAgentStore, MemoryApiAgentStore + +GRAPHQL_API_ID = "graphql:https://api.example.com/graphql" +GRAPHQL_SCHEMA = '{"types":[]}' + + +@pytest.fixture(autouse=True) +def description_store(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr(description, "ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store)) + return store + + +@pytest.mark.asyncio +async def test_description_generation_is_cached(monkeypatch): + calls = [] + + async def generate(**kwargs): + calls.append(kwargs) + return description.DescriptionResult( + text="Query objectives, key results, owners, status, and cycle data." + ) + + monkeypatch.setattr(description, "_generate_downstream_description", generate) + + first = await description.get_downstream_description( + api_type="rest", + hostname="okr.example.com", + raw_schema='{"openapi":"3.0.0"}', + api_id="rest:https://spec|https://api", + schema_hash="abc", + ) + second = await description.get_downstream_description( + api_type="rest", + hostname="okr.example.com", + raw_schema='{"openapi":"3.0.0"}', + api_id="rest:https://spec|https://api", + schema_hash="abc", + ) + + assert first == "Query objectives, key results, owners, status, and cycle data." + assert second == first + assert len(calls) == 1 + + +@pytest.mark.asyncio +async def test_description_generation_falls_back_without_failing_list_tools(monkeypatch): + async def generate(**_kwargs): + raise RuntimeError("model unavailable") + + monkeypatch.setattr(description, "_generate_downstream_description", generate) + + result = await description.get_downstream_description( + api_type="graphql", + hostname="api.example.com", + raw_schema=GRAPHQL_SCHEMA, + api_id=GRAPHQL_API_ID, + schema_hash="abc", + ) + + assert result == description.fallback_downstream_description( + api_type="graphql", + hostname="api.example.com", + ) + + +@pytest.mark.asyncio +async def test_fallback_is_cached_briefly_after_generation_failure(monkeypatch, description_store): + async def fail(**_kwargs): + raise RuntimeError("model unavailable") + + monkeypatch.setattr(description, "_generate_downstream_description", fail) + result = await description.get_downstream_description( + api_type="graphql", + hostname="api.example.com", + raw_schema=GRAPHQL_SCHEMA, + api_id=GRAPHQL_API_ID, + schema_hash="abc", + ) + + assert result == description.fallback_downstream_description( + api_type="graphql", + hostname="api.example.com", + ) + assert description_store.get_downstream_description( + api_id=GRAPHQL_API_ID, + schema_hash="abc", + ) == description.fallback_downstream_description( + api_type="graphql", + hostname="api.example.com", + raw_schema=GRAPHQL_SCHEMA, + ) + + +@pytest.mark.asyncio +async def test_generated_fallback_uses_ttl_cache(monkeypatch, description_store): + async def fallback_result(**kwargs): + return description._fallback_result(**kwargs) + + monkeypatch.setattr(description, "_generate_downstream_description", fallback_result) + + result = await description.get_downstream_description( + api_type="graphql", + hostname="api.example.com", + raw_schema=GRAPHQL_SCHEMA, + api_id=GRAPHQL_API_ID, + schema_hash="abc", + ) + + assert result == description.fallback_downstream_description( + api_type="graphql", + hostname="api.example.com", + raw_schema=GRAPHQL_SCHEMA, + ) + cached = description_store._downstream_descriptions[(GRAPHQL_API_ID, "abc")] + assert cached[0] == result + assert cached[1] is not None + + +@pytest.mark.asyncio +async def test_description_generation_times_out_to_fallback(monkeypatch, description_store): + async def slow(**_kwargs): + await asyncio.sleep(1) + return "Query objectives, key results, owners, status, and cycle data." + + monkeypatch.setattr(description.settings, "DESCRIPTION_TIMEOUT_SECONDS", 0.01) + monkeypatch.setattr(description, "_generate_downstream_description", slow) + + result = await description.get_downstream_description( + api_type="graphql", + hostname="api.example.com", + raw_schema=GRAPHQL_SCHEMA, + api_id=GRAPHQL_API_ID, + schema_hash="abc", + ) + + assert result == description.fallback_downstream_description( + api_type="graphql", + hostname="api.example.com", + ) + assert description_store.get_downstream_description( + api_id=GRAPHQL_API_ID, + schema_hash="abc", + ) == description.fallback_downstream_description( + api_type="graphql", + hostname="api.example.com", + raw_schema=GRAPHQL_SCHEMA, + ) + + +def test_rest_fallback_without_openapi_description_uses_hardcoded_query_description(): + raw_schema = json.dumps( + { + "openapi": "3.0.0", + "info": {"title": "FastAPI"}, + "paths": {"/api/teams/": {"get": {"tags": ["teams"], "summary": "Get Teams"}}}, + } + ) + + result = description.fallback_downstream_description( + api_type="rest", + hostname="api.example-data.io", + raw_schema=raw_schema, + ) + + assert result.startswith("[api.example-data.io REST API] Ask a natural-language question") + assert "fresh API data" in result + + +def test_fallback_prefers_openapi_description(): + raw_schema = json.dumps( + { + "openapi": "3.0.0", + "info": { + "title": "Objectives API", + "description": "Manage objectives and key results.", + }, + "paths": {}, + } + ) + + result = description.fallback_downstream_description( + api_type="rest", + hostname="okr.example.com", + raw_schema=raw_schema, + ) + + assert result == "Manage objectives and key results." + + +def test_rest_fallback_cleans_and_bounds_long_openapi_description(): + raw_schema = json.dumps( + { + "openapi": "3.0.0", + "info": { + "title": "Objectives API", + "description": ( + "# Objectives API\n\n" + "**Search objectives, key results, owners, and status.** " + "This markdown-heavy description includes long implementation notes. " + + "Extra details. " + * 80 + ), + }, + "paths": {}, + } + ) + + result = description.fallback_downstream_description( + api_type="rest", + hostname="okr.example.com", + raw_schema=raw_schema, + ) + + assert result == "Objectives API Search objectives, key results, owners, and status" + assert len(result) <= 300 + assert "#" not in result + assert "*" not in result + + +def test_graphql_fallback_uses_introspection_descriptions(): + raw_schema = json.dumps( + { + "queryType": {"name": "Query"}, + "types": [ + { + "name": "Query", + "fields": [ + { + "name": "components", + "description": "Search component catalog records.", + "args": [], + } + ], + }, + { + "name": "Component", + "description": "Component ownership, dependencies, and lifecycle metadata.", + "fields": [{"name": "id"}], + }, + ], + } + ) + + result = description.fallback_downstream_description( + api_type="graphql", + hostname="catalog.example.com", + raw_schema=raw_schema, + ) + + assert result == ( + "Search component catalog records. " + "Component ownership, dependencies, and lifecycle metadata." + ) + + +def test_graphql_fallback_uses_query_field_names_without_introspection_descriptions(): + raw_schema = json.dumps( + { + "queryType": {"name": "Query"}, + "types": [ + { + "name": "Query", + "fields": [{"name": "components", "args": []}], + } + ], + } + ) + + result = description.fallback_downstream_description( + api_type="graphql", + hostname="catalog.example.com", + raw_schema=raw_schema, + ) + + assert result == "Ask about catalog.example.com data including components." + + +def test_graphql_fallback_skips_generic_domain_types(): + raw_schema = json.dumps( + { + "queryType": {"name": "Query"}, + "types": [ + { + "name": "ActionResult", + "description": "Generic result returned by mutations.", + "fields": [{"name": "success"}], + }, + { + "name": "AuditEvent", + "description": "Audit metadata recording when an action occurred.", + "fields": [{"name": "at"}], + }, + { + "name": "Compliance", + "description": "Component-level compliance classification.", + "fields": [{"name": "id"}], + }, + { + "name": "Component", + "description": "Component is the central catalog entity. Used for ownership.", + "fields": [{"name": "id"}], + }, + ], + } + ) + + result = description.fallback_downstream_description( + api_type="graphql", + hostname="catalog.example.com", + raw_schema=raw_schema, + ) + + assert result == ( + "Component is the central catalog entity. Component-level compliance classification." + ) + + +def test_openapi_description_context_uses_spec_domain(): + raw_schema = json.dumps( + { + "openapi": "3.0.0", + "info": { + "title": "Objectives API", + "description": "Manage objectives and key results.", + "version": "1.0", + }, + "paths": { + "/objectives": { + "get": { + "summary": "List objectives", + "operationId": "listObjectives", + "tags": ["Objectives"], + } + }, + "/objectives/{id}/key-results": { + "get": { + "summary": "List key results", + "operationId": "listKeyResults", + } + }, + }, + } + ) + + context = description.build_description_context(api_type="rest", raw_schema=raw_schema) + + assert context["title"] == "Objectives API" + assert context["description"] == "Manage objectives and key results." + assert context["paths"][0]["summary"] == "List objectives" + assert context["paths"][1]["operation_id"] == "listKeyResults" + + +def test_graphql_description_context_uses_query_fields_and_domain_types(): + raw_schema = json.dumps( + { + "queryType": {"name": "Query"}, + "mutationType": {"name": "Mutation"}, + "types": [ + { + "name": "Query", + "fields": [ + { + "name": "objectives", + "description": "Search objectives", + "args": [{"name": "cycle"}], + } + ], + }, + { + "name": "Objective", + "description": "Company objective", + "fields": [{"name": "id"}, {"name": "status"}], + }, + ], + } + ) + + context = description.build_description_context(api_type="graphql", raw_schema=raw_schema) + + assert context["query_fields"] == [ + {"name": "objectives", "description": "Search objectives", "args": ["cycle"]} + ] + assert context["domain_types"][0]["name"] == "Objective" + + +def test_description_prompt_has_strict_output_contract(): + prompt = description.DESCRIPTION_INSTRUCTIONS + + assert "OUTPUT: Structured description." in prompt + assert '"description": ""' in prompt + assert "DESCRIPTION REQUIREMENTS:" in prompt + assert "DO NOT:" in prompt + + +def test_description_prompt_forbids_internal_terms(): + prompt = description.DESCRIPTION_INSTRUCTIONS + + assert "Do not mention API Agent" in prompt + assert "OpenAPI, GraphQL, MCP" in prompt + assert "Do not mention implementation details" in prompt + + +def test_description_prompt_has_guidance_not_examples(): + prompt = description.DESCRIPTION_INSTRUCTIONS + + assert "GOOD EXAMPLES" not in prompt + assert "BAD EXAMPLES" not in prompt + assert "Say what the downstream service actually does" in prompt + assert "agent choosing whether to call this general natural-language query tool" in prompt + + +@pytest.mark.asyncio +async def test_description_model_name_is_optional(monkeypatch): + used = {} + + class DummyAgent: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def run(*_args, **_kwargs): + return type( + "Result", + (), + { + "final_output": description.DownstreamDescriptionOutput( + description="Query objectives, key results, owners, status, and cycle data." + ) + }, + )() + + def create_model(api, model_name, client): + used["api"] = api + used["model_name"] = model_name + used["client"] = client + return object() + + monkeypatch.setattr(description.settings, "DESCRIPTION_MODEL_NAME", "") + monkeypatch.setattr(description.settings, "MODEL_NAME", "gpt-main") + monkeypatch.setattr(description, "Agent", DummyAgent) + monkeypatch.setattr(description.Runner, "run", run) + monkeypatch.setattr("api_agent.agent.model.create_openai_model", create_model) + + result = await description._generate_downstream_description( + api_type="graphql", + hostname="api.example.com", + raw_schema='{"types":[]}', + ) + + assert result.text == "Query objectives, key results, owners, status, and cycle data." + assert result.ttl_seconds is None + assert used["model_name"] == "gpt-main" + + +@pytest.mark.asyncio +async def test_invalid_generated_description_uses_rest_description_fallback(monkeypatch): + class DummyAgent: + def __init__(self, **_kwargs): + pass + + async def run(*_args, **_kwargs): + return type("Result", (), {"final_output": "not structured"})() + + raw_schema = json.dumps( + { + "openapi": "3.0.0", + "info": {"description": "Manage objectives and key results."}, + "paths": {}, + } + ) + monkeypatch.setattr(description, "Agent", DummyAgent) + monkeypatch.setattr(description.Runner, "run", run) + monkeypatch.setattr("api_agent.agent.model.create_openai_model", lambda *_args: object()) + + result = await description._generate_downstream_description( + api_type="rest", + hostname="okr.example.com", + raw_schema=raw_schema, + ) + + assert result.text == "Manage objectives and key results." + assert result.ttl_seconds == description._FALLBACK_CACHE_TTL_SECONDS + + +@pytest.mark.asyncio +async def test_description_generation_initializes_turn_context(monkeypatch): + class DummyAgent: + def __init__(self, **_kwargs): + pass + + async def run(*_args, **_kwargs): + from api_agent.agent.progress import get_turn_context + + assert get_turn_context(1) == "Turn 0/1" + return type( + "Result", + (), + { + "final_output": description.DownstreamDescriptionOutput( + description="Query objectives, key results, owners, status, and cycle data." + ) + }, + )() + + monkeypatch.setattr(description, "Agent", DummyAgent) + monkeypatch.setattr(description.Runner, "run", run) + monkeypatch.setattr("api_agent.agent.model.create_openai_model", lambda *_args: object()) + + result = await description._generate_downstream_description( + api_type="graphql", + hostname="api.example.com", + raw_schema='{"types":[]}', + ) + + assert result.text == "Query objectives, key results, owners, status, and cycle data." + assert result.ttl_seconds is None diff --git a/tests/test_executor.py b/tests/test_executor.py index aa9c789..3ff257f 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -5,7 +5,6 @@ from api_agent.executor import ( execute_sql, extract_tables_from_response, - get_table_schema_summary, truncate_for_context, ) @@ -140,6 +139,31 @@ def test_aggregation(self): assert result["success"] is True assert result["result"][0]["total"] == 600 + def test_date_results_are_json_serializable(self): + data = {"items": [{"id": 1, "due_date": "2026-05-29"}]} + result = execute_sql( + data, + """ + SELECT + CAST(due_date AS DATE) AS due_date, + CAST(due_date || ' 12:34:56' AS TIMESTAMP) AS due_at, + date_trunc('day', CAST(due_date AS DATE)) AS due_day + FROM items + """, + ) + + assert result == { + "success": True, + "result": [ + { + "due_date": "2026-05-29", + "due_at": "2026-05-29 12:34:56", + "due_day": "2026-05-29 00:00:00", + } + ], + } + json.dumps(result) + def test_invalid_sql_returns_error(self): """Invalid SQL returns error.""" data = {"users": [{"id": 1}]} @@ -157,45 +181,6 @@ def test_missing_table_returns_error(self): assert "error" in result -class TestGetTableSchemaSummary: - """Test get_table_schema_summary function.""" - - def test_extracts_schema_from_simple_data(self): - """Extracts column names and types.""" - data = [{"id": 1, "name": "Alice", "active": True}] - result = get_table_schema_summary(data, "users") - - assert result["rows"] == 1 - assert "id" in result["schema"] - assert "name" in result["schema"] - assert "BIGINT" in result["schema"] or "INTEGER" in result["schema"] - assert "VARCHAR" in result["schema"] - assert "hint" in result - - def test_extracts_nested_struct_types(self): - """Detects nested STRUCT types.""" - data = [{"user": {"id": 1, "name": "Alice"}}] - result = get_table_schema_summary(data, "response") - - assert result["rows"] == 1 - assert "STRUCT" in result["schema"] - assert "hint" in result - - def test_empty_data_returns_empty_schema(self): - """Empty list returns empty schema.""" - result = get_table_schema_summary([], "empty") - - assert result["rows"] == 0 - assert result["schema"] == "" - - def test_hint_contains_table_name(self): - """Hint includes table name for queries.""" - data = [{"id": 1}] - result = get_table_schema_summary(data, "my_table") - - assert "my_table" in result["hint"] - - class TestTruncateForContext: """Test truncate_for_context function.""" diff --git a/tests/test_graphql_client.py b/tests/test_graphql_client.py deleted file mode 100644 index 1534c82..0000000 --- a/tests/test_graphql_client.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Tests for GraphQL client behavior.""" - -import httpx -import pytest - -from api_agent.graphql.client import execute_query - - -def _mock_response(json_data=None, raise_status=None): - """Create mock response with optional error.""" - - class _Response: - def raise_for_status(self): - if raise_status: - raise raise_status - return None - - def json(self): - return json_data - - return _Response() - - -def _mock_client(response_or_fn): - """Create mock async client that returns response or calls fn.""" - - class _Client: - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def post(self, endpoint, json, headers): - if callable(response_or_fn): - return response_or_fn(endpoint, json, headers) - return response_or_fn - - return _Client() - - -@pytest.mark.asyncio -async def test_execute_query_requires_endpoint(): - result = await execute_query("query { users { id } }", endpoint="") - assert result["success"] is False - assert result["error"] == "No endpoint provided" - - -@pytest.mark.asyncio -async def test_execute_query_blocks_mutations(): - result = await execute_query("mutation { createUser(name: \"x\") { id } }", endpoint="https://api") - assert result["success"] is False - assert result["error"] == "Mutations are not allowed (read-only mode)" - - -@pytest.mark.asyncio -async def test_execute_query_success_includes_variables(monkeypatch): - captured: dict = {} - - def _post(endpoint, json, headers): - captured["endpoint"] = endpoint - captured["json"] = json - captured["headers"] = headers - return _mock_response(json_data={"data": {"users": [{"id": 1}]}}) - - client = _mock_client(_post) - monkeypatch.setattr("api_agent.graphql.client.httpx.AsyncClient", lambda **_kwargs: client) - result = await execute_query( - "query GetUser($id: ID!) { user(id: $id) { id } }", - variables={"id": "u1"}, - endpoint="https://api.example.com/graphql", - headers={"Authorization": "Bearer t"}, - ) - - assert result == {"success": True, "data": {"users": [{"id": 1}]}} - assert captured["endpoint"] == "https://api.example.com/graphql" - assert captured["json"]["variables"] == {"id": "u1"} - assert captured["headers"]["Authorization"] == "Bearer t" - - -@pytest.mark.asyncio -async def test_execute_query_success_without_variables_omits_key(monkeypatch): - captured: dict = {} - - def _post(endpoint, json, headers): - captured["json"] = json - return _mock_response(json_data={"data": {"ok": True}}) - - client = _mock_client(_post) - monkeypatch.setattr("api_agent.graphql.client.httpx.AsyncClient", lambda **_kwargs: client) - result = await execute_query("query { ping }", endpoint="https://api.example.com/graphql") - assert result == {"success": True, "data": {"ok": True}} - assert "variables" not in captured["json"] - - -@pytest.mark.asyncio -async def test_execute_query_returns_graphql_errors(monkeypatch): - response = _mock_response(json_data={"errors": [{"message": "bad field"}]}) - client = _mock_client(response) - monkeypatch.setattr("api_agent.graphql.client.httpx.AsyncClient", lambda **_kwargs: client) - result = await execute_query("query { badField }", endpoint="https://api.example.com/graphql") - assert result["success"] is False - assert result["error"] == [{"message": "bad field"}] - - -@pytest.mark.asyncio -async def test_execute_query_returns_http_status_error(monkeypatch): - request = httpx.Request("POST", "https://api.example.com/graphql") - response = httpx.Response(404, request=request) - http_error = httpx.HTTPStatusError("Not found", request=request, response=response) - mock_resp = _mock_response(raise_status=http_error) - client = _mock_client(mock_resp) - - monkeypatch.setattr("api_agent.graphql.client.httpx.AsyncClient", lambda **_kwargs: client) - result = await execute_query("query { users { id } }", endpoint="https://api.example.com/graphql") - assert result["success"] is False - assert result["error"] == "HTTP 404" - assert result["status_code"] == 404 - - -@pytest.mark.asyncio -async def test_execute_query_returns_generic_exception(monkeypatch): - def _raise_error(endpoint, json, headers): - raise RuntimeError("boom") - - client = _mock_client(_raise_error) - monkeypatch.setattr("api_agent.graphql.client.httpx.AsyncClient", lambda **_kwargs: client) - result = await execute_query("query { users { id } }", endpoint="https://api.example.com/graphql") - assert result["success"] is False - assert result["error"] == "boom" diff --git a/tests/test_http_errors.py b/tests/test_http_errors.py deleted file mode 100644 index 80a102f..0000000 --- a/tests/test_http_errors.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Tests for HTTP error detail helpers.""" - -import httpx - -from api_agent.utils.http_errors import build_http_error_response, extract_http_error_details - - -def _response(status: int, *, json_body=None, text_body: str = "") -> httpx.Response: - request = httpx.Request("GET", "https://api.example.com/test") - if json_body is not None: - return httpx.Response(status, request=request, json=json_body) - return httpx.Response(status, request=request, text=text_body) - - -def test_extract_http_error_details_prefers_errors_field(): - response = _response(400, json_body={"errors": [{"message": "bad field"}], "message": "ignored"}) - assert extract_http_error_details(response) == [{"message": "bad field"}] - - -def test_extract_http_error_details_limits_text_fallback(): - response = _response(500, text_body="x" * 5000) - details = extract_http_error_details(response) - assert isinstance(details, str) - assert len(details) == 1000 - - -def test_build_http_error_response_includes_status_and_details(): - response = _response(404, json_body={"error": "missing"}) - request = response.request - exc = httpx.HTTPStatusError("not found", request=request, response=response) - - out = build_http_error_response(exc) - assert out["success"] is False - assert out["error"] == "HTTP 404" - assert out["status_code"] == 404 - assert out["details"] == "missing" diff --git a/tests/test_individual_recipe_tools.py b/tests/test_individual_recipe_tools.py index d5d12b9..8ced5b6 100644 --- a/tests/test_individual_recipe_tools.py +++ b/tests/test_individual_recipe_tools.py @@ -2,13 +2,12 @@ from unittest.mock import MagicMock -from api_agent.recipe import ( +from api_agent.recipe.execution import build_partial_result, validate_recipe_params +from api_agent.recipe.search import build_api_id +from api_agent.recipe.tooling import ( _sanitize_for_tool_name, - build_api_id, - build_partial_result, build_recipe_docstring, deduplicate_tool_name, - validate_recipe_params, ) @@ -21,7 +20,7 @@ def test_sanitize_for_tool_name_basic(): def test_sanitize_for_tool_name_special_chars(): """Test sanitization removes special characters.""" assert _sanitize_for_tool_name("Get user's recent posts") == "get_users_recent_posts" - assert _sanitize_for_tool_name("Fetch data (v2)") == "fetch_data_v2" + assert _sanitize_for_tool_name("Fetch data beta") == "fetch_data_beta" assert _sanitize_for_tool_name("Query: users + posts") == "query_users_posts" assert _sanitize_for_tool_name("Get user's 'data'!") == "get_users_data" @@ -55,8 +54,8 @@ def test_sanitize_for_tool_name_digit_prefix(): def test_validate_recipe_params_success(): """Test successful param validation.""" params_spec = { - "user_id": {"type": "int", "default": 123}, - "limit": {"type": "int", "default": 10}, + "user_id": {"type": "int", "description": "User id"}, + "limit": {"type": "int", "description": "Limit"}, } provided = {"user_id": 456, "limit": 10} params, error = validate_recipe_params(params_spec, provided) @@ -67,8 +66,8 @@ def test_validate_recipe_params_success(): def test_validate_recipe_params_missing_required(): """Test validation fails on missing required param.""" params_spec = { - "user_id": {"type": "int"}, # No default = required - "limit": {"type": "int", "default": 10}, + "user_id": {"type": "int", "description": "User id"}, + "limit": {"type": "int", "description": "Limit"}, } provided = {} params, error = validate_recipe_params(params_spec, provided) @@ -76,18 +75,6 @@ def test_validate_recipe_params_missing_required(): assert "missing required param: user_id" in error -def test_validate_recipe_params_with_defaults(): - """Test params merge with defaults.""" - params_spec = { - "user_id": {"type": "int", "default": 123}, - "query": {"type": "str", "default": "test"}, - } - provided = {"user_id": 123, "query": "custom"} - params, error = validate_recipe_params(params_spec, provided) - assert error == "" - assert params == {"user_id": 123, "query": "custom"} - - def test_validate_recipe_params_empty_spec(): """Test validation with no params.""" params_spec = {} @@ -100,7 +87,7 @@ def test_validate_recipe_params_empty_spec(): def test_validate_recipe_params_extra_provided(): """Test validation rejects extra params.""" params_spec = { - "user_id": {"type": "int", "default": 123}, + "user_id": {"type": "int", "description": "User id"}, } provided = {"user_id": 456, "extra": "ignored"} params, error = validate_recipe_params(params_spec, provided) @@ -108,50 +95,34 @@ def test_validate_recipe_params_extra_provided(): assert "unexpected params: extra" in error -# build_recipe_docstring tests - - -def test_build_recipe_docstring_rest_single_step(): - """Test docstring for single REST API call.""" - docstring = build_recipe_docstring( - "Get user data", steps=[{"kind": "rest"}], sql_steps=[], api_type="rest" - ) - assert "Get user data" in docstring - assert "1 API call" in docstring - - -def test_build_recipe_docstring_graphql_multiple(): - """Test docstring for multiple GraphQL queries.""" - docstring = build_recipe_docstring( - "Get users and posts", - steps=[{"kind": "graphql"}, {"kind": "graphql"}], - sql_steps=[], - api_type="graphql", - ) - assert "Get users and posts" in docstring - assert "2 GraphQL queries" in docstring - - -def test_build_recipe_docstring_sql_only(): - """Test docstring for SQL-only recipe.""" +def test_build_recipe_docstring_normalizes_stored_description(): + """Stored descriptions are exposed without generated API context.""" docstring = build_recipe_docstring( - "Run SQL query", steps=[], sql_steps=["SELECT * FROM data"], api_type="rest" + "Get user data", + steps=[], + description="[api.example.com GraphQL API] Use for listing users. Returns user ids as CSV.", ) - assert "Run SQL query" in docstring - assert "1 SQL step" in docstring + assert docstring == "Use for listing users. Returns user ids as CSV." -def test_build_recipe_docstring_mixed_steps(): - """Test docstring for mixed API + SQL steps.""" +def test_build_recipe_docstring_ignores_step_counts(): + """Step counts stay out of MCP tool descriptions.""" docstring = build_recipe_docstring( "Complex workflow", - steps=[{"kind": "rest"}, {"kind": "rest"}], - sql_steps=["SELECT 1", "SELECT 2", "SELECT 3"], + steps=[ + {"kind": "rest"}, + {"kind": "rest"}, + {"kind": "sql", "query_template": "SELECT 1"}, + {"kind": "sql", "query_template": "SELECT 2"}, + {"kind": "sql", "query_template": "SELECT 3"}, + ], api_type="rest", + description="Use for complex workflows. Returns matching rows as CSV.", ) - assert "Complex workflow" in docstring - assert "2 API calls" in docstring - assert "3 SQL steps" in docstring + assert "Use for complex workflows" in docstring + assert "API call" not in docstring + assert "SQL transform" not in docstring + assert "step" not in docstring.lower() # deduplicate_tool_name tests diff --git a/tests/test_middleware.py b/tests/test_middleware.py index b35a51e..9ab5715 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -10,93 +10,187 @@ from mcp.types import Tool from api_agent.context import RequestContext -from api_agent.middleware import DynamicToolNamingMiddleware, _get_tool_suffix, _inject_api_context -from api_agent.recipe.store import RecipeStore, sha256_hex +from api_agent.middleware import SPECIFIC_TOOL_HINT, DynamicToolNamingMiddleware +from api_agent.store import AsyncApiAgentStore, MemoryApiAgentStore, sha256_hex -def _dummy_list_context() -> MiddlewareContext[mt.ListToolsRequest]: - """Create a dummy MiddlewareContext for on_list_tools tests.""" - return cast(MiddlewareContext[mt.ListToolsRequest], object()) - +@pytest.fixture(autouse=True) +def mock_downstream_description(monkeypatch): + async def fake_description(**_kwargs): + return "Query users, teams, and reporting data from the downstream service." -class TestGetToolSuffix: - """Test internal tool name suffix extraction.""" + monkeypatch.setattr("api_agent.middleware.get_downstream_description", fake_description) - def test_underscore_prefix_query(self): - assert _get_tool_suffix("_query") == "query" - def test_underscore_prefix_execute(self): - assert _get_tool_suffix("_execute") == "execute" - - def test_no_underscore_prefix(self): - assert _get_tool_suffix("query") == "query" +def _dummy_list_context() -> MiddlewareContext[mt.ListToolsRequest]: + """Create a dummy MiddlewareContext for on_list_tools tests.""" + return cast(MiddlewareContext[mt.ListToolsRequest], object()) - def test_double_underscore(self): - assert _get_tool_suffix("__private") == "_private" +def _recipe( + tool_name: str = "list_users", + description: str = "Use for listing users. Returns user ids as CSV. No required params. Do not use for different user fields, joins, or workflows.", + tool_args: dict | None = None, + steps: list | None = None, +) -> dict: + return { + "public_contract": { + "tool_name": tool_name, + "description": description, + "tool_args": tool_args or {}, + }, + "execution_plan": { + "steps": steps if steps is not None else [_graphql_step("users", "{ users { id } }")], + }, + "validation_fixture": {"tool_args": {}}, + } + + +def _graphql_step(step_id: str, query_template: str, with_vars: dict | None = None) -> dict: + return { + "id": step_id, + "kind": "graphql", + "input": {"mode": "single", "with": with_vars or {}}, + "call": {"query_template": query_template}, + "output": {"name": step_id}, + } -class TestInjectApiContext: - """Test description injection with full hostname.""" - def test_rest_api_context(self): - desc = "Ask questions in natural language." - result = _inject_api_context(desc, "flights-api.example.com", "rest") - assert result == "[flights-api.example.com REST API] Ask questions in natural language." +class TestToolTransformation: + """Test user-visible tool transformation.""" - def test_graphql_api_context(self): - desc = "Query the API." - result = _inject_api_context(desc, "catalog-graphql.example.com", "graphql") - assert result == "[catalog-graphql.example.com GraphQL API] Query the API." + @pytest.mark.asyncio + async def test_explicit_api_name_preserves_hyphen_and_uses_underscore_separator(self): + middleware = DynamicToolNamingMiddleware() + req_ctx = RequestContext( + target_url="https://api.example.com/graphql", + api_type="graphql", + target_headers={}, + allow_unsafe_paths=(), + base_url=None, + include_result=False, + poll_paths=(), + ) - def test_empty_description(self): - result = _inject_api_context("", "api.example.com", "rest") - assert result == "[api.example.com REST API] " + async def call_next(_context): + return [Tool(name="_query", description="Query", inputSchema={"type": "object"})] + with patch("api_agent.middleware.get_http_headers") as mock_headers: + with patch("api_agent.middleware.get_request_context", return_value=req_ctx): + with patch( + "api_agent.middleware.load_schema_and_base_url", + new_callable=AsyncMock, + ) as mock_fetch: + mock_fetch.return_value = ('{"schema":"ok"}', "") + mock_headers.return_value = { + "x-target-url": req_ctx.target_url, + "x-api-type": req_ctx.api_type, + "x-api-name": "weather-alerts", + } + tools = await middleware.on_list_tools(_dummy_list_context(), call_next) -class TestToolTransformation: - """Test tool name and description transformation.""" + names = [t.name for t in tools] + assert "weather-alerts_query" in names + query_tool = next(t for t in tools if t.name == "weather-alerts_query") + assert query_tool.description == ( + "Query users, teams, and reporting data from the downstream service." + ) + assert SPECIFIC_TOOL_HINT not in (query_tool.description or "") - def test_tool_name_with_prefix(self): - """Verify tool names use prefix + suffix format.""" - prefix = "flights_api_example" - internal_name = "_query" - suffix = _get_tool_suffix(internal_name) - expected = f"{prefix}_{suffix}" - assert expected == "flights_api_example_query" - assert len(expected) <= 32 + 6 + 1 # prefix(32) + suffix + underscore + @pytest.mark.asyncio + async def test_query_tool_mentions_specific_tools_when_available(self, monkeypatch): + middleware = DynamicToolNamingMiddleware() + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr("api_agent.middleware.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store)) + monkeypatch.setattr("api_agent.middleware.settings.ENABLE_RECIPES", True) - def test_description_includes_full_hostname(self): - """Verify descriptions include full hostname.""" - hostname = "flights-api-qa.internal.example.com" - result = _inject_api_context("Test.", hostname, "rest") - assert hostname in result - assert "REST API" in result + raw_schema = '{"schema":"ok"}' + schema_hash = sha256_hex(raw_schema) + recipe = _recipe() + store.save_recipe( + api_id="graphql:https://api.example.com/graphql", + schema_hash=schema_hash, + question="List users", + recipe=recipe, + tool_name=recipe["public_contract"]["tool_name"], + ) + req_ctx = RequestContext( + target_url="https://api.example.com/graphql", + api_type="graphql", + target_headers={}, + allow_unsafe_paths=(), + base_url=None, + include_result=False, + poll_paths=(), + ) + async def call_next(_context): + return [Tool(name="_query", description="Query", inputSchema={"type": "object"})] -class TestBuildRecipeToolName: - """Test recipe tool name construction and truncation.""" + with patch("api_agent.middleware.get_http_headers") as mock_headers: + with patch("api_agent.middleware.get_request_context", return_value=req_ctx): + with patch( + "api_agent.middleware.load_schema_and_base_url", + new_callable=AsyncMock, + ) as mock_fetch: + mock_fetch.return_value = (raw_schema, "") + mock_headers.return_value = { + "x-target-url": req_ctx.target_url, + "x-api-type": req_ctx.api_type, + } + tools = await middleware.on_list_tools(_dummy_list_context(), call_next) - def test_short_name(self): - from api_agent.middleware import _build_recipe_tool_name + query_tool = next(t for t in tools if t.name.endswith("_query")) + assert SPECIFIC_TOOL_HINT in (query_tool.description or "") + assert "recipe" not in (query_tool.description or "").lower() + assert "r_*" not in (query_tool.description or "") - assert _build_recipe_tool_name("get_users") == "r_get_users" + @pytest.mark.asyncio + async def test_list_tools_loads_schema_once_for_description_and_recipe_tools(self, monkeypatch): + middleware = DynamicToolNamingMiddleware() + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr("api_agent.middleware.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store)) + monkeypatch.setattr("api_agent.middleware.settings.ENABLE_RECIPES", True) - def test_truncates_long_slug(self): - from api_agent.middleware import MAX_TOOL_NAME_LEN, _build_recipe_tool_name + raw_schema = '{"schema":"ok"}' + schema_hash = sha256_hex(raw_schema) + recipe = _recipe() + store.save_recipe( + api_id="graphql:https://api.example.com/graphql", + schema_hash=schema_hash, + question="List users", + recipe=recipe, + tool_name=recipe["public_contract"]["tool_name"], + ) + req_ctx = RequestContext( + target_url="https://api.example.com/graphql", + api_type="graphql", + target_headers={}, + allow_unsafe_paths=(), + base_url=None, + include_result=False, + poll_paths=(), + ) - slug = "a" * 100 - result = _build_recipe_tool_name(slug) - assert len(result) <= MAX_TOOL_NAME_LEN - assert result.startswith("r_") + async def call_next(_context): + return [Tool(name="_query", description="Query", inputSchema={"type": "object"})] - def test_exact_boundary(self): - from api_agent.middleware import MAX_TOOL_NAME_LEN, _build_recipe_tool_name + with patch("api_agent.middleware.get_http_headers") as mock_headers: + with patch("api_agent.middleware.get_request_context", return_value=req_ctx): + with patch( + "api_agent.middleware.load_schema_and_base_url", + new_callable=AsyncMock, + ) as mock_fetch: + mock_fetch.return_value = (raw_schema, "") + mock_headers.return_value = { + "x-target-url": req_ctx.target_url, + "x-api-type": req_ctx.api_type, + } + tools = await middleware.on_list_tools(_dummy_list_context(), call_next) - # slug that exactly fills MAX_TOOL_NAME_LEN - 2 (for "r_") - slug = "x" * (MAX_TOOL_NAME_LEN - 2) - result = _build_recipe_tool_name(slug) - assert result == f"r_{slug}" - assert len(result) == MAX_TOOL_NAME_LEN + assert mock_fetch.await_count == 1 + assert any(t.name.endswith("_query") for t in tools) + assert any(t.name.startswith("r_") for t in tools) class TestSchemaLoadGuards: @@ -171,31 +265,31 @@ class TestRecipeToolListing: @pytest.mark.asyncio async def test_recipe_tool_uses_slug_without_api_prefix(self, monkeypatch): middleware = DynamicToolNamingMiddleware() - store = RecipeStore(max_size=10) - monkeypatch.setattr("api_agent.middleware.RECIPE_STORE", store) + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr("api_agent.middleware.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store)) monkeypatch.setattr("api_agent.middleware.settings.ENABLE_RECIPES", True) raw_schema = '{"schema":"ok"}' api_id = "graphql:https://api.example.com/graphql" schema_hash = sha256_hex(raw_schema) - recipe = { - "tool_name": "list_users_reporting_to_manager", - "params": {"manager_name": {"type": "str", "default": "Jane Doe"}}, - "steps": [ - { - "kind": "graphql", - "name": "users", - "query_template": "{ users { name } }", - } + recipe = _recipe( + tool_name="list_users_reporting_to_manager", + description="Use for listing users who report to one manager. Returns user names as CSV. Requires manager_name. Do not use for different reporting chains, joins, or extra fields.", + tool_args={"manager_name": {"type": "str", "description": "Manager name"}}, + steps=[ + _graphql_step( + "users", + '{ users(manager: "{{manager_name}}") { name } }', + {"manager_name": {"value": "manager_name"}}, + ) ], - "sql_steps": [], - } + ) store.save_recipe( api_id=api_id, schema_hash=schema_hash, question="List users reporting to manager", recipe=recipe, - tool_name=recipe["tool_name"], + tool_name=recipe["public_contract"]["tool_name"], ) req_ctx = RequestContext( @@ -226,38 +320,111 @@ async def call_next(_context): names = [t.name for t in tools] assert "r_list_users_reporting_to_manager" in names - assert all(len(name) <= 60 for name in names) + assert all(len(name) < 50 for name in names) + tool = next(t for t in tools if t.name == "r_list_users_reporting_to_manager") + assert tool.description == recipe["public_contract"]["description"] + assert "[" not in (tool.description or "") + assert "Required params:" not in (tool.description or "") + assert "Executes:" not in (tool.description or "") + schema = tool.model_dump().get("parameters", {}) + assert "manager_name" in schema.get("properties", {}) + assert schema["properties"]["manager_name"]["description"] == "Manager name" + + @pytest.mark.asyncio + async def test_recipe_tool_name_is_truncated_below_50_including_prefix(self, monkeypatch): + middleware = DynamicToolNamingMiddleware() + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr("api_agent.middleware.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store)) + monkeypatch.setattr("api_agent.middleware.settings.ENABLE_RECIPES", True) + + raw_schema = '{"schema":"ok"}' + api_id = "graphql:https://api.example.com/graphql" + schema_hash = sha256_hex(raw_schema) + recipe = _recipe( + tool_name="list_users_with_extremely_long_recipe_name_that_needs_truncation_for_limits", + tool_args={"manager_name": {"type": "str", "description": "Manager name"}}, + steps=[ + _graphql_step( + "users", + '{ users(manager: "{{manager_name}}") { name } }', + {"manager_name": {"value": "manager_name"}}, + ) + ], + ) + store.save_recipe( + api_id=api_id, + schema_hash=schema_hash, + question="List users reporting to manager", + recipe=recipe, + tool_name=recipe["public_contract"]["tool_name"], + ) + + req_ctx = RequestContext( + target_url="https://api.example.com/graphql", + api_type="graphql", + target_headers={}, + allow_unsafe_paths=(), + base_url=None, + include_result=False, + poll_paths=(), + ) + + async def call_next(_context): + return [Tool(name="_query", description="Query", inputSchema={"type": "object"})] + + with patch("api_agent.middleware.get_http_headers") as mock_headers: + with patch("api_agent.middleware.get_request_context", return_value=req_ctx): + with patch( + "api_agent.middleware.load_schema_and_base_url", + new_callable=AsyncMock, + ) as mock_fetch: + mock_fetch.return_value = (raw_schema, "") + mock_headers.return_value = { + "x-target-url": req_ctx.target_url, + "x-api-type": req_ctx.api_type, + } + tools = await middleware.on_list_tools(_dummy_list_context(), call_next) + + recipe_tools = [t for t in tools if t.name.startswith("r_")] + assert recipe_tools, "expected at least one recipe tool" + assert all(len(t.name) < 50 for t in recipe_tools) class TestRecipeToolSchema: """Test recipe tool input schema.""" @pytest.mark.asyncio - async def test_all_params_required_even_with_defaults(self, monkeypatch): - """Defaults are example values; all params must be explicitly provided.""" + async def test_all_tool_args_required(self, monkeypatch): + """All public tool args must be explicitly provided.""" middleware = DynamicToolNamingMiddleware() - store = RecipeStore(max_size=10) - monkeypatch.setattr("api_agent.middleware.RECIPE_STORE", store) + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr("api_agent.middleware.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store)) monkeypatch.setattr("api_agent.middleware.settings.ENABLE_RECIPES", True) raw_schema = '{"schema":"ok"}' api_id = "graphql:https://api.example.com/graphql" schema_hash = sha256_hex(raw_schema) - recipe = { - "tool_name": "list_users", - "params": { - "user_id": {"type": "int", "default": 1}, - "active": {"type": "bool", "default": True}, + recipe = _recipe( + tool_name="list_users", + description="Use for listing users by id and active state. Returns user ids as CSV. Requires user_id and active. Do not use for different user fields, joins, or workflows.", + tool_args={ + "user_id": {"type": "int", "description": "User id"}, + "active": {"type": "bool", "description": "Active flag"}, }, - "steps": [{"kind": "graphql", "name": "users", "query_template": "{ users { id } }"}], - "sql_steps": [], - } + steps=[ + _graphql_step( + "users", + "{ users(id: {{user_id}}, active: {{active}}) { id } }", + {"user_id": {"value": "user_id"}, "active": {"value": "active"}}, + ) + ], + ) store.save_recipe( api_id=api_id, schema_hash=schema_hash, question="List users", recipe=recipe, - tool_name=recipe["tool_name"], + tool_name=recipe["public_contract"]["tool_name"], ) req_ctx = RequestContext( @@ -288,41 +455,45 @@ async def call_next(_context): tool = next(t for t in tools if t.name == "r_list_users") schema = tool.model_dump().get("parameters", {}) - # Params are top-level (flat), not nested under "params" + # Tool args are top-level, not nested under "params" assert "params" not in schema.get("properties", {}) assert sorted(schema.get("required", [])) == ["active", "user_id"] - # Defaults shown as description hints, not JSON Schema defaults assert "default" not in schema["properties"]["user_id"] assert "default" not in schema["properties"]["active"] - # return_directly is optional top-level field - assert "return_directly" in schema["properties"] + assert "return_directly" not in schema["properties"] @pytest.mark.asyncio - async def test_default_none_has_no_description_hint(self, monkeypatch): - """Params with default=None get no 'e.g.' hint; non-None defaults do.""" + async def test_tool_arg_descriptions_from_public_contract(self, monkeypatch): + """Tool arg descriptions come from public_contract.tool_args.""" middleware = DynamicToolNamingMiddleware() - store = RecipeStore(max_size=10) - monkeypatch.setattr("api_agent.middleware.RECIPE_STORE", store) + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr("api_agent.middleware.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store)) monkeypatch.setattr("api_agent.middleware.settings.ENABLE_RECIPES", True) raw_schema = '{"schema":"ok"}' api_id = "graphql:https://api.example.com/graphql" schema_hash = sha256_hex(raw_schema) - recipe = { - "tool_name": "list_users", - "params": { - "user_id": {"type": "int", "default": None}, - "active": {"type": "bool", "default": True}, + recipe = _recipe( + tool_name="list_users", + description="Use for listing users by id and active state. Returns user ids as CSV. Requires user_id and active. Do not use for different user fields, joins, or workflows.", + tool_args={ + "user_id": {"type": "int", "description": "User id"}, + "active": {"type": "bool", "description": "Active flag"}, }, - "steps": [{"kind": "graphql", "name": "users", "query_template": "{ users { id } }"}], - "sql_steps": [], - } + steps=[ + _graphql_step( + "users", + "{ users(id: {{user_id}}, active: {{active}}) { id } }", + {"user_id": {"value": "user_id"}, "active": {"value": "active"}}, + ) + ], + ) store.save_recipe( api_id=api_id, schema_hash=schema_hash, question="List users", recipe=recipe, - tool_name=recipe["tool_name"], + tool_name=recipe["public_contract"]["tool_name"], ) req_ctx = RequestContext( @@ -353,21 +524,36 @@ async def call_next(_context): tool = next(t for t in tools if t.name == "r_list_users") schema = tool.model_dump().get("parameters", {}) - # Params are top-level (flat) + # Tool args are top-level. assert "params" not in schema.get("properties", {}) - # Both params required assert sorted(schema.get("required", [])) == ["active", "user_id"] - # default=None -> "Required" only; default=True -> has "e.g." hint uid_props = schema["properties"]["user_id"] active_props = schema["properties"]["active"] - assert "e.g." not in uid_props.get("description", "") - assert "Required" in uid_props.get("description", "") - assert "e.g." in active_props.get("description", "") + assert uid_props.get("description") == "User id" + assert active_props.get("description") == "Active flag" class TestRecipeToolErrors: """Ensure recipe tools signal errors via MCP exceptions.""" + @pytest.mark.asyncio + async def test_unknown_tool_is_not_bypassed(self): + middleware = DynamicToolNamingMiddleware() + + async def call_next(_context): + return None + + message = mt.CallToolRequestParams(name="other_tool", arguments={}) + context = MiddlewareContext(message=message) + + with patch("api_agent.middleware.get_http_headers") as mock_headers: + mock_headers.return_value = { + "x-target-url": "https://api.example.com/graphql", + "x-api-type": "graphql", + } + with pytest.raises(NotFoundError, match="Expected tool name starting"): + await middleware.on_call_tool(context, call_next) + @pytest.mark.asyncio async def test_recipe_tool_invalid_arguments_raises_validation_error(self): middleware = DynamicToolNamingMiddleware() @@ -385,9 +571,7 @@ async def call_next(_context): return None # Non-dict arguments should raise ValidationError - message = mt.CallToolRequestParams.model_construct( - name="r_test", arguments="not_a_dict" - ) + message = mt.CallToolRequestParams.model_construct(name="r_test", arguments="not_a_dict") context = MiddlewareContext(message=message) with patch("api_agent.middleware.get_http_headers") as mock_headers: @@ -399,6 +583,66 @@ async def call_next(_context): with pytest.raises(ValidationError, match="Invalid arguments"): await middleware.on_call_tool(context, call_next) + @pytest.mark.asyncio + async def test_recipe_tool_invalid_param_type_raises_validation_error(self, monkeypatch): + middleware = DynamicToolNamingMiddleware() + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr("api_agent.middleware.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store)) + monkeypatch.setattr( + "api_agent.recipe.runner.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + + raw_schema = '{"schema":"ok"}' + api_id = "graphql:https://api.example.com/graphql" + schema_hash = sha256_hex(raw_schema) + recipe = _recipe( + tool_name="list_users", + tool_args={"limit": {"type": "int", "description": "Limit"}}, + steps=[ + _graphql_step( + "users", + "{ users(limit: {{limit}}) { id } }", + {"limit": {"value": "limit"}}, + ) + ], + ) + store.save_recipe( + api_id=api_id, + schema_hash=schema_hash, + question="List users", + recipe=recipe, + tool_name=recipe["public_contract"]["tool_name"], + ) + req_ctx = RequestContext( + target_url="https://api.example.com/graphql", + api_type="graphql", + target_headers={}, + allow_unsafe_paths=(), + base_url=None, + include_result=False, + poll_paths=(), + ) + + async def call_next(_context): + return None + + message = mt.CallToolRequestParams(name="r_list_users", arguments={"limit": "ten"}) + context = MiddlewareContext(message=message) + + with patch("api_agent.middleware.get_http_headers") as mock_headers: + with patch("api_agent.middleware.get_request_context", return_value=req_ctx): + with patch( + "api_agent.middleware.load_schema_and_base_url", + new_callable=AsyncMock, + ) as mock_fetch: + mock_fetch.return_value = (raw_schema, "") + mock_headers.return_value = { + "x-target-url": req_ctx.target_url, + "x-api-type": req_ctx.api_type, + } + with pytest.raises(ValidationError, match="invalid param type: limit"): + await middleware.on_call_tool(context, call_next) + @pytest.mark.asyncio async def test_recipe_tool_not_found_raises_not_found(self): middleware = DynamicToolNamingMiddleware() @@ -430,8 +674,31 @@ async def call_next(_context): "x-api-type": req_ctx.api_type, } with patch( - "api_agent.middleware.RECIPE_STORE.find_recipe_by_tool_slug", + "api_agent.middleware.ASYNC_API_AGENT_STORE.find_recipe_by_tool_slug", return_value=None, ): with pytest.raises(NotFoundError, match="recipe not found"): await middleware.on_call_tool(context, call_next) + + +class TestCallToolNameValidation: + """Validate explicit vs implicit tool name prefixes.""" + + @pytest.mark.asyncio + async def test_explicit_api_name_with_hyphen_accepts_underscore_separator(self): + middleware = DynamicToolNamingMiddleware() + + async def call_next(context): + return context.message.name + + message = mt.CallToolRequestParams(name="weather-alerts_query", arguments={}) + context = MiddlewareContext(message=message) + + with patch("api_agent.middleware.get_http_headers") as mock_headers: + mock_headers.return_value = { + "x-api-name": "weather-alerts", + "x-target-url": "https://api.example.com/graphql", + "x-api-type": "graphql", + } + internal = await middleware.on_call_tool(context, call_next) + assert internal == "_query" diff --git a/tests/test_model_config.py b/tests/test_model_config.py new file mode 100644 index 0000000..926a9e2 --- /dev/null +++ b/tests/test_model_config.py @@ -0,0 +1,29 @@ +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from agents.models.openai_responses import OpenAIResponsesModel +from openai import AsyncOpenAI + +from api_agent.agent import model as agent_model + + +def test_shared_model_uses_responses_api(): + assert isinstance(agent_model.model, OpenAIResponsesModel) + + +def test_create_openai_model_supports_both_apis(): + client = AsyncOpenAI(api_key="test") + + assert isinstance( + agent_model.create_openai_model("responses", "gpt-5.5", client), + OpenAIResponsesModel, + ) + assert isinstance( + agent_model.create_openai_model("chat_completions", "gpt-4.1", client), + OpenAIChatCompletionsModel, + ) + + +def test_run_config_serializes_tool_execution(): + run_config = agent_model.get_run_config() + + assert run_config.tool_execution is not None + assert run_config.tool_execution.max_function_tool_concurrency == 1 diff --git a/tests/test_poll_tool.py b/tests/test_poll_tool.py index 49076ca..bcafff0 100644 --- a/tests/test_poll_tool.py +++ b/tests/test_poll_tool.py @@ -1,77 +1,15 @@ -"""Tests for poll_until_done tool helper functions and logic.""" +"""Tests for poll_until_done tool behavior.""" import pytest +from agents.tool_context import ToolContext -from api_agent.agent.rest_agent import _get_nested_value, _set_nested_value - - -class TestGetNestedValue: - """Test dot-notation value extraction.""" - - def test_simple_key(self): - data = {"foo": "bar"} - assert _get_nested_value(data, "foo") == "bar" - - def test_nested_key(self): - data = {"polling": {"completed": True}} - assert _get_nested_value(data, "polling.completed") is True - - def test_deep_nested(self): - data = {"a": {"b": {"c": {"d": 42}}}} - assert _get_nested_value(data, "a.b.c.d") == 42 - - def test_missing_key_returns_none(self): - data = {"foo": "bar"} - assert _get_nested_value(data, "missing") is None - - def test_missing_nested_returns_none(self): - data = {"foo": {"bar": 1}} - assert _get_nested_value(data, "foo.missing.deep") is None - - def test_empty_path_returns_none(self): - data = {"foo": "bar"} - assert _get_nested_value(data, "") is None - - def test_none_data_returns_none(self): - assert _get_nested_value(None, "foo") is None - - def test_array_index(self): - data = {"trips": [{"id": 1}, {"id": 2}]} - assert _get_nested_value(data, "trips.0.id") == 1 - assert _get_nested_value(data, "trips.1.id") == 2 - - def test_array_index_out_of_bounds(self): - data = {"trips": [{"id": 1}]} - assert _get_nested_value(data, "trips.5.id") is None - - def test_array_nested_completion(self): - """Real-world case: trips.0.isCompleted.""" - data = {"trips": [{"isCompleted": True, "results": []}]} - assert _get_nested_value(data, "trips.0.isCompleted") is True - - -class TestSetNestedValue: - """Test dot-notation value setting.""" - - def test_simple_key(self): - data = {"foo": "bar"} - _set_nested_value(data, "foo", "baz") - assert data["foo"] == "baz" - - def test_nested_key(self): - data = {"polling": {"count": 1}} - _set_nested_value(data, "polling.count", 2) - assert data["polling"]["count"] == 2 - - def test_creates_nested_structure(self): - data = {} - _set_nested_value(data, "a.b.c", 42) - assert data["a"]["b"]["c"] == 42 - - def test_empty_path_does_nothing(self): - data = {"foo": "bar"} - _set_nested_value(data, "", "baz") - assert data == {"foo": "bar"} +TOOL_CONTEXT = ToolContext( + context=None, + tool_name="poll_until_done", + tool_call_id="test", + tool_arguments="{}", + run_config=None, +) class TestPollBlocking: @@ -81,8 +19,8 @@ class TestPollBlocking: async def test_post_blocked_without_whitelist(self): import json - from api_agent.agent.rest_agent import _create_poll_tool from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool ctx = RequestContext( target_url="", @@ -93,10 +31,10 @@ async def test_post_blocked_without_whitelist(self): include_result=False, poll_paths=(), ) - poll_tool = _create_poll_tool(ctx, "https://api.example.com") + poll_tool = create_poll_tool(ctx, "https://api.example.com") result = await poll_tool.on_invoke_tool( - None, + TOOL_CONTEXT, json.dumps( { "method": "POST", @@ -115,8 +53,8 @@ async def test_post_blocked_without_whitelist(self): async def test_post_allowed_with_whitelist(self): import json - from api_agent.agent.rest_agent import _create_poll_tool from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool ctx = RequestContext( target_url="", @@ -127,10 +65,10 @@ async def test_post_allowed_with_whitelist(self): include_result=False, poll_paths=(), ) - poll_tool = _create_poll_tool(ctx, "https://api.example.com") + poll_tool = create_poll_tool(ctx, "https://api.example.com") result = await poll_tool.on_invoke_tool( - None, + TOOL_CONTEXT, json.dumps( { "method": "POST", @@ -155,8 +93,8 @@ async def test_done_field_not_found_returns_error(self): import json from unittest.mock import AsyncMock, patch - from api_agent.agent.rest_agent import _create_poll_tool from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool ctx = RequestContext( target_url="", @@ -167,18 +105,18 @@ async def test_done_field_not_found_returns_error(self): include_result=False, poll_paths=(), ) - poll_tool = _create_poll_tool(ctx, "https://api.example.com") + poll_tool = create_poll_tool(ctx, "https://api.example.com") # Mock response without the expected done_field mock_response = {"status": "pending", "results": []} with patch( - "api_agent.agent.rest_agent.execute_request", + "api_agent.rest.polling.execute_request", new_callable=AsyncMock, return_value={"success": True, "data": mock_response}, ): result = await poll_tool.on_invoke_tool( - None, + TOOL_CONTEXT, json.dumps( { "method": "POST", @@ -202,8 +140,8 @@ async def test_agent_specified_delay_ms(self): import time from unittest.mock import AsyncMock, patch - from api_agent.agent.rest_agent import _create_poll_tool from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool ctx = RequestContext( target_url="", @@ -214,7 +152,7 @@ async def test_agent_specified_delay_ms(self): include_result=False, poll_paths=(), ) - poll_tool = _create_poll_tool(ctx, "https://api.example.com") + poll_tool = create_poll_tool(ctx, "https://api.example.com") call_times = [] @@ -227,12 +165,12 @@ async def mock_request(*args, **kwargs): } with patch( - "api_agent.agent.rest_agent.execute_request", + "api_agent.rest.polling.execute_request", new_callable=AsyncMock, side_effect=mock_request, ): result = await poll_tool.on_invoke_tool( - None, + TOOL_CONTEXT, json.dumps( { "method": "POST", @@ -249,14 +187,69 @@ async def mock_request(*args, **kwargs): actual_delay = call_times[1] - call_times[0] assert actual_delay < 1.0 # 100ms + tolerance + @pytest.mark.asyncio + async def test_delay_ms_is_capped(self, monkeypatch): + """Agent-specified delay_ms is capped by server settings.""" + import json + from unittest.mock import AsyncMock, patch + + from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool + + monkeypatch.setattr("api_agent.rest.polling.settings.MAX_POLL_DELAY_MS", 25) + + ctx = RequestContext( + target_url="", + api_type="rest", + target_headers={}, + allow_unsafe_paths=("/search/*",), + base_url=None, + include_result=False, + poll_paths=(), + ) + poll_tool = create_poll_tool(ctx, "https://api.example.com") + calls = 0 + + async def mock_request(*args, **kwargs): + nonlocal calls + calls += 1 + return { + "success": True, + "data": {"polling": {"completed": calls >= 2}}, + } + + with ( + patch( + "api_agent.rest.polling.execute_request", + new_callable=AsyncMock, + side_effect=mock_request, + ), + patch("api_agent.rest.polling.asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + result = await poll_tool.on_invoke_tool( + TOOL_CONTEXT, + json.dumps( + { + "method": "POST", + "path": "/search/flights", + "done_field": "polling.completed", + "done_value": "true", + "delay_ms": 60000, + } + ), + ) + + assert json.loads(result)["success"] is True + sleep.assert_awaited_once_with(0.025) + @pytest.mark.asyncio async def test_max_polls_error_shows_last_value(self): """max_polls exceeded should show last done_field value.""" import json from unittest.mock import AsyncMock, patch - from api_agent.agent.rest_agent import _create_poll_tool from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool ctx = RequestContext( target_url="", @@ -267,10 +260,10 @@ async def test_max_polls_error_shows_last_value(self): include_result=False, poll_paths=(), ) - poll_tool = _create_poll_tool(ctx, "https://api.example.com") + poll_tool = create_poll_tool(ctx, "https://api.example.com") with patch( - "api_agent.agent.rest_agent.execute_request", + "api_agent.rest.polling.execute_request", new_callable=AsyncMock, return_value={ "success": True, @@ -278,7 +271,7 @@ async def test_max_polls_error_shows_last_value(self): }, ): result = await poll_tool.on_invoke_tool( - None, + TOOL_CONTEXT, json.dumps( { "method": "POST", @@ -295,14 +288,64 @@ async def test_max_polls_error_shows_last_value(self): # Should show what the actual value was assert "false" in result_dict["error"].lower() or "False" in result_dict["error"] + @pytest.mark.asyncio + async def test_max_polls_does_not_sleep_after_final_attempt(self, monkeypatch): + import json + from unittest.mock import AsyncMock, patch + + from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool + + monkeypatch.setattr("api_agent.rest.polling.settings.MAX_POLLS", 1) + + ctx = RequestContext( + target_url="", + api_type="rest", + target_headers={}, + allow_unsafe_paths=("/search/*",), + base_url=None, + include_result=False, + poll_paths=(), + ) + poll_tool = create_poll_tool(ctx, "https://api.example.com") + + with ( + patch( + "api_agent.rest.polling.execute_request", + new_callable=AsyncMock, + return_value={ + "success": True, + "data": {"polling": {"completed": False}}, + }, + ), + patch("api_agent.rest.polling.asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + result = await poll_tool.on_invoke_tool( + TOOL_CONTEXT, + json.dumps( + { + "method": "POST", + "path": "/search/flights", + "done_field": "polling.completed", + "done_value": "true", + "delay_ms": 60000, + } + ), + ) + + result_dict = json.loads(result) + assert result_dict["success"] is False + assert result_dict["attempts"] == 1 + sleep.assert_not_awaited() + @pytest.mark.asyncio async def test_auto_increment_polling_count(self): """polling.count in body should auto-increment between polls.""" import json from unittest.mock import AsyncMock, patch - from api_agent.agent.rest_agent import _create_poll_tool from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool ctx = RequestContext( target_url="", @@ -313,7 +356,7 @@ async def test_auto_increment_polling_count(self): include_result=False, poll_paths=(), ) - poll_tool = _create_poll_tool(ctx, "https://api.example.com") + poll_tool = create_poll_tool(ctx, "https://api.example.com") received_bodies = [] @@ -327,12 +370,12 @@ async def mock_request(*args, body=None, **kwargs): } with patch( - "api_agent.agent.rest_agent.execute_request", + "api_agent.rest.polling.execute_request", new_callable=AsyncMock, side_effect=mock_request, ): result = await poll_tool.on_invoke_tool( - None, + TOOL_CONTEXT, json.dumps( { "method": "POST", @@ -357,8 +400,8 @@ async def test_numeric_done_field_zero_means_done(self): import json from unittest.mock import AsyncMock, patch - from api_agent.agent.rest_agent import _create_poll_tool from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool ctx = RequestContext( target_url="", @@ -369,7 +412,7 @@ async def test_numeric_done_field_zero_means_done(self): include_result=False, poll_paths=(), ) - poll_tool = _create_poll_tool(ctx, "https://api.example.com") + poll_tool = create_poll_tool(ctx, "https://api.example.com") call_count = 0 @@ -383,12 +426,12 @@ async def mock_request(*args, **kwargs): } with patch( - "api_agent.agent.rest_agent.execute_request", + "api_agent.rest.polling.execute_request", new_callable=AsyncMock, side_effect=mock_request, ): result = await poll_tool.on_invoke_tool( - None, + TOOL_CONTEXT, json.dumps( { "method": "POST", @@ -408,8 +451,8 @@ async def test_invalid_body_json_returns_error(self): """Invalid body JSON should return a friendly error.""" import json - from api_agent.agent.rest_agent import _create_poll_tool from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool ctx = RequestContext( target_url="", @@ -420,10 +463,10 @@ async def test_invalid_body_json_returns_error(self): include_result=False, poll_paths=(), ) - poll_tool = _create_poll_tool(ctx, "https://api.example.com") + poll_tool = create_poll_tool(ctx, "https://api.example.com") result = await poll_tool.on_invoke_tool( - None, + TOOL_CONTEXT, json.dumps( { "method": "POST", @@ -444,8 +487,8 @@ async def test_works_without_polling_count_in_body(self): import json from unittest.mock import AsyncMock, patch - from api_agent.agent.rest_agent import _create_poll_tool from api_agent.context import RequestContext + from api_agent.rest.polling import create_poll_tool ctx = RequestContext( target_url="", @@ -456,7 +499,7 @@ async def test_works_without_polling_count_in_body(self): include_result=False, poll_paths=(), ) - poll_tool = _create_poll_tool(ctx, "https://api.example.com") + poll_tool = create_poll_tool(ctx, "https://api.example.com") call_count = 0 @@ -469,12 +512,12 @@ async def mock_request(*args, **kwargs): } with patch( - "api_agent.agent.rest_agent.execute_request", + "api_agent.rest.polling.execute_request", new_callable=AsyncMock, side_effect=mock_request, ): result = await poll_tool.on_invoke_tool( - None, + TOOL_CONTEXT, json.dumps( { "method": "POST", diff --git a/tests/test_query_response.py b/tests/test_query_response.py index 3667dbc..f9fcff0 100644 --- a/tests/test_query_response.py +++ b/tests/test_query_response.py @@ -1,107 +1,109 @@ -"""Tests for query response building.""" +"""Tests for CSV response formatting.""" -from unittest.mock import MagicMock +from types import SimpleNamespace -import pytest - -from api_agent.tools.query import _build_response +from api_agent.query_response import QueryResponse +from api_agent.tools.query import _should_include_result, _should_return_csv from api_agent.utils.csv import to_csv -@pytest.fixture -def ctx_without_include_result(): - """Context with include_result=False.""" - ctx = MagicMock() - ctx.include_result = False - return ctx - - -@pytest.fixture -def ctx_with_include_result(): - """Context with include_result=True.""" - ctx = MagicMock() - ctx.include_result = True - return ctx - - -class TestBuildResponse: - """Tests for _build_response function.""" - - def test_direct_return_response_rest(self, ctx_without_include_result): - """Direct return response includes result and api_calls only.""" - result = { - "ok": True, - "result": {"users": [{"id": 1}]}, - "api_calls": [{"method": "GET", "path": "/users"}], - } - response = _build_response(result, "api_calls", ctx_without_include_result) - - assert response["ok"] is True - assert response["result"] == {"users": [{"id": 1}]} - assert response["api_calls"] == [{"method": "GET", "path": "/users"}] - # Should not have cruft fields - assert "direct_return" not in response - - def test_direct_return_response_graphql(self, ctx_without_include_result): - """Direct return response includes result and queries only.""" - result = { - "ok": True, - "result": {"data": {"users": []}}, - "queries": ["query { users { id } }"], - } - response = _build_response(result, "queries", ctx_without_include_result) - - assert response["ok"] is True - assert response["result"] == {"data": {"users": []}} - assert response["queries"] == ["query { users { id } }"] - assert "direct_return" not in response - - def test_result_included_when_present(self, ctx_without_include_result): - """Result is included when present, even without include_result flag.""" - result = { - "ok": True, - "result": {"data": "value"}, - "api_calls": [], - } - response = _build_response(result, "api_calls", ctx_without_include_result) +class TestQueryResponse: + """Tests for normalized query response envelope.""" - assert "result" in response - assert response["result"] == {"data": "value"} + def test_wrapped_payload_hides_result_and_calls_by_default(self): + response = QueryResponse.from_agent_result( + { + "ok": True, + "data": "done", + "result": [{"id": 1}], + "queries": ["{ users { id } }"], + "error": None, + }, + "queries", + ) - def test_result_included_when_include_result_true(self, ctx_with_include_result): - """Result included when include_result=True.""" - result = { + assert response.to_mcp_payload() == { "ok": True, - "result": None, - "api_calls": [], + "data": "done", + "error": None, } - response = _build_response(result, "api_calls", ctx_with_include_result) - - assert "result" in response - def test_result_excluded_when_none_and_not_requested(self, ctx_without_include_result): - """Result excluded when None and include_result=False.""" - result = { - "ok": True, - "api_calls": [], + def test_result_can_be_included_in_payload(self): + response = QueryResponse.from_agent_result( + {"ok": True, "data": "done", "result": [{"id": 1}], "api_calls": [], "error": None}, + "api_calls", + ) + + assert response.to_mcp_payload(include_result=True)["result"] == [{"id": 1}] + + def test_missing_result_is_not_forced_into_payload(self): + response = QueryResponse.from_agent_result( + {"ok": True, "data": "done", "api_calls": [], "error": None}, + "api_calls", + ) + + assert "result" not in response.to_mcp_payload(include_result=True) + + def test_direct_return_csv_marker(self): + response = QueryResponse.from_agent_result( + {"ok": True, "data": None, "result": [{"id": 1}], "queries": []}, + "queries", + ) + + assert response.should_return_csv is True + + def test_debug_payload_includes_calls_and_trace_id(self): + response = QueryResponse.from_agent_result( + { + "ok": True, + "data": "done", + "api_calls": [{"method": "GET"}], + "trace_id": "abc123", + "error": None, + }, + "api_calls", + ) + + assert response.to_mcp_payload(include_debug=True)["debug"] == { + "api_calls": [{"method": "GET"}], + "trace_id": "abc123", } - response = _build_response(result, "api_calls", ctx_without_include_result) - - assert "result" not in response - - def test_empty_api_calls(self, ctx_without_include_result): - """Empty api_calls list is preserved.""" - result = {"ok": True, "api_calls": []} - response = _build_response(result, "api_calls", ctx_without_include_result) - - assert response["api_calls"] == [] - - def test_empty_queries(self, ctx_without_include_result): - """Empty queries list is preserved.""" - result = {"ok": True, "queries": []} - response = _build_response(result, "queries", ctx_without_include_result) - assert response["queries"] == [] + def test_debug_direct_return_includes_result(self): + response = QueryResponse.from_agent_result( + {"ok": True, "data": None, "result": [{"id": 1}], "api_calls": []}, + "api_calls", + ) + req_ctx = SimpleNamespace(include_result=False, debug=True) + + assert _should_include_result(response, req_ctx) is True + + def test_query_return_directly_returns_csv_when_rows_exist(self): + response = QueryResponse.from_agent_result( + {"ok": True, "data": "summary", "result": [{"id": 1}], "api_calls": []}, + "api_calls", + ) + req_ctx = SimpleNamespace(debug=False) + + assert _should_return_csv(response, req_ctx, return_directly=True) is True + + def test_query_return_directly_still_wraps_without_rows(self): + response = QueryResponse.from_agent_result( + {"ok": True, "data": "summary", "api_calls": []}, + "api_calls", + ) + req_ctx = SimpleNamespace(debug=False) + + assert _should_return_csv(response, req_ctx, return_directly=True) is False + + def test_query_debug_wraps_even_when_return_directly_requested(self): + response = QueryResponse.from_agent_result( + {"ok": True, "data": "summary", "result": [{"id": 1}], "api_calls": []}, + "api_calls", + ) + req_ctx = SimpleNamespace(debug=True) + + assert _should_return_csv(response, req_ctx, return_directly=True) is False class TestToCsv: diff --git a/tests/test_recipe_extraction.py b/tests/test_recipe_extraction.py index baeca72..c7ad891 100644 --- a/tests/test_recipe_extraction.py +++ b/tests/test_recipe_extraction.py @@ -1,123 +1,755 @@ """Tests for recipe extraction and deduplication.""" +import json +import logging +from copy import deepcopy +from types import SimpleNamespace + import pytest +from agents import AgentOutputSchema +from agents.exceptions import ModelBehaviorError + +from api_agent.executor import execute_sql +from api_agent.recipe.contracts import resolve_step_input_values +from api_agent.recipe.extractor import extract_recipe +from api_agent.recipe.extractor_models import ExtractedRecipeOutput +from api_agent.recipe.extractor_prompts import build_extractor_instructions +from api_agent.recipe.learning import maybe_extract_and_save_recipe +from api_agent.store import AsyncApiAgentStore, MemoryApiAgentStore, sha256_hex + + +def _graphql_recipe() -> dict: + return { + "public_contract": { + "tool_name": "list_users", + "description": "Use for listing users. Returns user ids as CSV. No required params. Do not use for different user fields, joins, or workflows.", + "tool_args": {}, + }, + "execution_plan": { + "steps": [ + { + "id": "users", + "kind": "graphql", + "input": {"mode": "single", "with": {}}, + "call": {"query_template": "{ users { id } }"}, + "output": {"name": "users"}, + } + ], + }, + "validation_fixture": {"tool_args": {}}, + } + + +def _graphql_step(step_id: str, query_template: str, with_vars: dict | None = None) -> dict: + return { + "id": step_id, + "kind": "graphql", + "input": {"mode": "single", "with": with_vars or {}}, + "call": {"query_template": query_template}, + "output": {"name": step_id}, + } + + +def _sql_step(step_id: str, query_template: str, with_vars: dict | None = None) -> dict: + return { + "id": step_id, + "kind": "sql", + "input": {"mode": "single", "with": with_vars or {}}, + "query_template": query_template, + "output": {"name": step_id}, + } + + +def _rest_step( + step_id: str, + path: str, + *, + input_spec: dict | None = None, + path_params: dict | None = None, + query_params: dict | None = None, + body: dict | None = None, + output: dict | None = None, + method: str = "GET", +) -> dict: + return { + "id": step_id, + "kind": "rest", + "input": input_spec or {"mode": "single", "with": {}}, + "call": { + "method": method, + "path": path, + "path_params": path_params or {}, + "query_params": query_params or {}, + "body": body or {}, + }, + "output": output or {"name": step_id}, + } + + +async def _async_result(value): + return value + + +def test_build_extractor_instructions_uses_graphql_section(): + instructions = build_extractor_instructions("graphql") + + assert "GRAPHQL RECIPE RULES" in instructions + assert "GRAPHQL MAP EXAMPLE" in instructions + assert "REST RECIPE RULES" not in instructions + assert '"kind": "graphql"' in instructions + assert '"kind": "rest"' not in instructions + + +def test_build_extractor_instructions_uses_rest_section(): + instructions = build_extractor_instructions("rest") + + assert "REST RECIPE RULES" in instructions + assert "REST MAP EXAMPLE" in instructions + assert "GRAPHQL RECIPE RULES" not in instructions + assert '"kind": "rest"' in instructions + + +def test_contains_pattern_matches_separator_variants(): + step = _sql_step( + "filtered_objectives", + "SELECT id FROM objectives WHERE lower(assignee.team.name) LIKE '{{team_pattern}}'", + {"team_pattern": {"value": "team", "transform": "contains_pattern"}}, + ) + input_sets, error = resolve_step_input_values(step, {"team": "alpha-suite"}, {}) + assert error == "" + assert input_sets is not None + + sql = step["query_template"].replace("{{team_pattern}}", input_sets[0][0]["team_pattern"]) + result = execute_sql( + {"objectives": [{"id": 1, "assignee": {"team": {"name": "Alpha Suite"}}}]}, + sql, + ) + + assert result["result"] == [{"id": 1}] + + +def test_contains_pattern_splits_underscores(): + step = _sql_step( + "filtered_objectives", + "SELECT id FROM objectives WHERE lower(assignee.team.name) LIKE '{{team_pattern}}'", + {"team_pattern": {"value": "team", "transform": "contains_pattern"}}, + ) + input_sets, error = resolve_step_input_values(step, {"team": "alpha_suite"}, {}) + assert error == "" + assert input_sets is not None + + sql = step["query_template"].replace("{{team_pattern}}", input_sets[0][0]["team_pattern"]) + result = execute_sql( + {"objectives": [{"id": 1, "assignee": {"team": {"name": "Alpha Suite"}}}]}, + sql, + ) + + assert result["result"] == [{"id": 1}] + + +@pytest.mark.asyncio +async def test_extract_recipe_relaxes_public_string_sql_equality(monkeypatch): + recipe = { + "public_contract": { + "tool_name": "list_team_okrs", + "description": "Use for listing OKRs for a specific team in a specific cycle. Returns objectives with their key results, owners, and status as rows. Requires team and cycle. Do not use for different fields, joins, or workflow.", + "tool_args": { + "team": {"type": "str", "description": "Team name"}, + "cycle": {"type": "str", "description": "Cycle"}, + }, + }, + "execution_plan": { + "steps": [ + _rest_step( + "objectives", + "/api/v2/objectives", + input_spec={"mode": "single", "with": {"cycle": {"value": "cycle"}}}, + query_params={"cycle": {"$var": "cycle"}}, + ), + _sql_step( + "filtered_objectives", + ( + "SELECT id FROM objectives " + "WHERE lower(assignee.team.name) = lower('{{team_name}}')" + ), + {"team_name": {"value": "team"}}, + ), + ], + }, + "validation_fixture": {"tool_args": {"team": "alpha-suite", "cycle": "Y2026Q2"}}, + } + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="rest", + question="List alpha-suite OKRs for Q2 2026", + steps=[], + ) + + assert result is not None + sql_step = result["execution_plan"]["steps"][1] + assert "LIKE lower('{{team_name}}')" in sql_step["query_template"] + assert sql_step["input"]["with"]["team_name"]["transform"] == "contains_pattern" + + +@pytest.mark.asyncio +async def test_extract_recipe_keeps_canonical_string_sql_equality(monkeypatch): + recipe = { + "public_contract": { + "tool_name": "list_team_okrs", + "description": "Use for listing OKRs for a specific team in a specific cycle. Returns objective rows for that team. Requires team and cycle. Do not use for different fields, joins, or workflow.", + "tool_args": { + "team": {"type": "str", "description": "Team name"}, + "cycle": {"type": "str", "description": "Cycle"}, + }, + }, + "execution_plan": { + "steps": [ + _rest_step( + "objectives", + "/api/v2/objectives", + input_spec={"mode": "single", "with": {"cycle": {"value": "cycle"}}}, + query_params={"cycle": {"$var": "cycle"}}, + ), + _sql_step( + "filtered_objectives", + ( + "SELECT id FROM objectives " + "WHERE lower(assignee.team.name) = lower('{{team_name}}')" + ), + {"team_name": {"value": "team"}}, + ), + ], + }, + "validation_fixture": {"tool_args": {"team": "Alpha Suite", "cycle": "Y2026Q2"}}, + } + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="rest", + question="List Alpha Suite OKRs for Q2 2026", + steps=[], + ) + + assert result is not None + sql_step = result["execution_plan"]["steps"][1] + assert " = lower('{{team_name}}')" in sql_step["query_template"] + assert "transform" not in sql_step["input"]["with"]["team_name"] + + +@pytest.mark.asyncio +async def test_extract_recipe_accepts_structured_output(monkeypatch): + recipe = _graphql_recipe() + + async def run_agent(agent, _input, max_turns, run_config): + assert max_turns == 2 + assert isinstance(agent.output_type, AgentOutputSchema) + assert agent.output_type.output_type is ExtractedRecipeOutput + assert not agent.output_type.is_strict_json_schema() + assert "GRAPHQL RECIPE RULES" in agent.instructions + assert "REST RECIPE RULES" not in agent.instructions + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="graphql", + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + ) + + assert result == recipe + + +@pytest.mark.asyncio +async def test_extract_recipe_passes_validation_feedback(monkeypatch): + recipe = _graphql_recipe() + feedback = { + "reason": "candidate_result_mismatch", + "candidate_result": {"row_count": 0, "sample": []}, + "expected_result": {"row_count": 1, "sample": [{"id": 1}]}, + } + + async def run_agent(_agent, input_payload, max_turns, run_config): + assert max_turns == 2 + payload = json.loads(input_payload) + assert payload["validation_feedback"] == feedback + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="graphql", + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + validation_feedback=feedback, + ) + + assert result == recipe + + +@pytest.mark.asyncio +async def test_extract_recipe_accepts_interleaved_sql_steps(monkeypatch): + recipe: dict = { + "public_contract": { + "tool_name": "get_user_order_total", + "description": "Use for calculating total order amount for one user. Returns summed order amount as CSV. Requires userId. Do not use for unrelated order fields or joins.", + "tool_args": {"userId": {"type": "int", "description": "User id"}}, + }, + "execution_plan": { + "steps": [ + _graphql_step( + "orders", + "{ orders(userId: {{userId}}) { id amount } }", + {"userId": {"value": "userId"}}, + ), + _sql_step("order_total", "SELECT SUM(amount) AS total FROM orders"), + ], + }, + "validation_fixture": {"tool_args": {"userId": 42}}, + } + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="graphql", + question="Total orders for user 42", + steps=[ + { + "kind": "graphql", + "name": "orders", + "query": "{ orders(userId: 42) { id amount } }", + }, + { + "kind": "sql", + "query": "SELECT SUM(amount) AS total FROM orders", + }, + ], + ) + + assert result == recipe + assert result is not None + assert "sql_steps" not in result + assert [step["kind"] for step in result["execution_plan"]["steps"]] == ["graphql", "sql"] + + +@pytest.mark.asyncio +async def test_extract_recipe_rejects_invalid_structured_output(monkeypatch): + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output={"tool_name": "list_users"}) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="graphql", + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + ) + + assert result is None + + +@pytest.mark.asyncio +async def test_extract_recipe_rejects_model_output_validation_error(monkeypatch): + async def run_agent(_agent, _input, max_turns, run_config): + raise ModelBehaviorError("invalid final output") + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="graphql", + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + ) + + assert result is None -from api_agent.recipe.common import maybe_extract_and_save_recipe -from api_agent.recipe.store import RecipeStore, sha256_hex + +@pytest.mark.asyncio +async def test_extract_recipe_rejects_missing_validation_fixture_for_public_args(monkeypatch): + recipe = _graphql_recipe() + recipe["public_contract"]["tool_args"] = {"userId": {"type": "int", "description": "User id"}} + recipe["execution_plan"]["steps"][0]["call"]["query_template"] = ( + "{ users(id: {{userId}}) { id } }" + ) + recipe["execution_plan"]["steps"][0]["input"]["with"] = {"userId": {"value": "userId"}} + del recipe["validation_fixture"] + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=recipe) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="graphql", + question="List user 1", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + ) + + assert result is None + + +@pytest.mark.asyncio +async def test_extract_recipe_rejects_reserved_tool_prefix(monkeypatch): + recipe = _graphql_recipe() + recipe["public_contract"]["tool_name"] = "r_list_users" + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="graphql", + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + ) + + assert result is None + + +@pytest.mark.asyncio +async def test_extract_recipe_rejects_unknown_template_var(monkeypatch): + recipe = _graphql_recipe() + recipe["execution_plan"]["steps"][0]["call"]["query_template"] = ( + "{ users(id: {{userId}}) { id } }" + ) + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="graphql", + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + ) + + assert result is None + + +@pytest.mark.asyncio +async def test_extract_recipe_rejects_missing_input_arg(monkeypatch): + recipe = _graphql_recipe() + recipe["public_contract"]["tool_args"] = {"userId": {"type": "int", "description": "User id"}} + recipe["execution_plan"]["steps"][0]["call"]["query_template"] = ( + "{ users(id: {{userId}}) { id } }" + ) + recipe["execution_plan"]["steps"][0]["input"]["with"] = {"userId": {"value": "missing"}} + recipe["validation_fixture"] = {"tool_args": {"userId": 1}} + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="graphql", + question="List user 1", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + ) + + assert result is None + + +@pytest.mark.asyncio +async def test_extract_recipe_rejects_hidden_step_result_rest_param(monkeypatch): + recipe = { + "public_contract": { + "tool_name": "get_team_okrs", + "description": "Use for looking up team OKRs by team and cycle. Returns objective and key result rows as CSV. Requires team_id and cycle. Do not use for different fields, joins, or workflows.", + "tool_args": { + "team_id": {"type": "int", "description": "Team id"}, + "cycle": {"type": "str", "description": "Cycle"}, + }, + }, + "execution_plan": {"steps": [{"kind": "rest", "name": "objectives_by_cycle"}]}, + "validation_fixture": {"tool_args": {"team_id": 3, "cycle": "Y2026Q2"}}, + } + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=recipe) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="rest", + question="Team 3 OKRs for Y2026Q2", + steps=[{"kind": "rest", "method": "GET", "path": "/objectives"}], + ) + + assert result is None + + +@pytest.mark.asyncio +async def test_extract_recipe_accepts_mapped_binding_source(monkeypatch): + recipe = { + "public_contract": { + "tool_name": "get_team_okrs", + "description": "Use for looking up team OKRs by team and cycle. Returns objective and key result rows as CSV. Requires team_id and cycle. Do not use for different fields, joins, or workflows.", + "tool_args": { + "team_id": {"type": "int", "description": "Team id"}, + "cycle": {"type": "str", "description": "Cycle"}, + }, + }, + "execution_plan": { + "steps": [ + _rest_step( + "objectives_by_cycle", + "/objectives", + input_spec={"mode": "single", "with": {"cycle": {"value": "cycle"}}}, + query_params={"cycle": {"$var": "cycle"}}, + ), + _sql_step( + "filtered_objectives", + ( + "SELECT id FROM objectives_by_cycle " + "WHERE assignee.team.id = {{team_id}} ORDER BY id" + ), + {"team_id": {"value": "team_id"}}, + ), + _rest_step( + "key_results_for_objective", + "/objectives/{objective_id}/key-results", + input_spec={ + "mode": "map", + "from": "filtered_objectives", + "bind": {"objective_id": "id"}, + "with": {}, + }, + path_params={"objective_id": {"$var": "objective_id"}}, + output={ + "name": "key_results_for_objective", + "attach_binding": ["objective_id"], + }, + ), + ], + }, + "validation_fixture": {"tool_args": {"team_id": 3, "cycle": "Y2026Q2"}}, + } + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="rest", + question="Team 3 OKRs for Y2026Q2", + steps=[{"kind": "rest", "method": "GET", "path": "/objectives"}], + ) + + assert result == recipe + + +@pytest.mark.asyncio +async def test_extract_recipe_rejects_unsupported_transform(monkeypatch): + recipe = _graphql_recipe() + recipe["public_contract"]["tool_args"] = {"team": {"type": "str", "description": "Team"}} + recipe["execution_plan"]["steps"][0]["input"]["with"] = { + "teamPattern": {"value": "team", "transform": "regex"} + } + recipe["execution_plan"]["steps"][0]["call"]["query_template"] = ( + '{ users(team: "{{teamPattern}}") { id } }' + ) + recipe["validation_fixture"] = {"tool_args": {"team": "alpha-suite"}} + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="graphql", + question="List alpha-suite users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + ) + + assert result is None + + +@pytest.mark.asyncio +async def test_extract_recipe_rejects_rest_write_candidate(monkeypatch): + recipe = { + "public_contract": { + "tool_name": "create_user", + "description": "Use for creating a user by name. Returns created user rows as CSV. Requires name. Do not use for different fields, joins, or workflows.", + "tool_args": {"name": {"type": "str", "description": "Name"}}, + }, + "execution_plan": { + "steps": [ + _rest_step( + "users", + "/users", + input_spec={"mode": "single", "with": {"name": {"value": "name"}}}, + body={"name": {"$var": "name"}}, + method="POST", + ) + ], + }, + "validation_fixture": {"tool_args": {"name": "Ada"}}, + } + + async def run_agent(_agent, _input, max_turns, run_config): + return SimpleNamespace(final_output=ExtractedRecipeOutput.model_validate(recipe)) + + monkeypatch.setattr("api_agent.recipe.extractor.Runner.run", run_agent) + + result = await extract_recipe( + api_type="rest", + question="Create Ada", + steps=[{"kind": "rest", "method": "POST", "path": "/users"}], + ) + + assert result is None @pytest.mark.asyncio async def test_skip_duplicate_recipe(monkeypatch): - store = RecipeStore(max_size=10) - monkeypatch.setattr("api_agent.recipe.common.RECIPE_STORE", store) - monkeypatch.setattr("api_agent.recipe.common.settings.ENABLE_RECIPES", True) + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) raw_schema = '{"schema":"ok"}' api_id = "graphql:https://api.example.com/graphql" schema_hash = sha256_hex(raw_schema) recipe = { - "tool_name": "list_users", - "params": {}, - "steps": [ - { - "kind": "graphql", - "name": "users", - "query_template": "{ users { id } }", - } - ], - "sql_steps": [], + **_graphql_recipe(), } store.save_recipe( api_id=api_id, schema_hash=schema_hash, question="List users", recipe=recipe, - tool_name=recipe["tool_name"], + tool_name=recipe["public_contract"]["tool_name"], ) async def fake_extract_recipe(**_kwargs): return dict(recipe) - monkeypatch.setattr("api_agent.recipe.common.extract_recipe", fake_extract_recipe) + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) - called = False - original_save = store.save_recipe + await maybe_extract_and_save_recipe( + api_type="graphql", + api_id=api_id, + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + raw_schema=raw_schema, + learn_rate=1, + original_result=[{"id": 1}], + validate_candidate=lambda _recipe, _args: _async_result([{"id": 1}]), + ) - def save_wrapper(*args, **kwargs): - nonlocal called - called = True - return original_save(*args, **kwargs) + assert len(store.list_recipes(api_id=api_id, schema_hash=schema_hash)) == 1 + + +@pytest.mark.asyncio +async def test_skip_duplicate_recipe_with_different_description(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + + raw_schema = '{"schema":"ok"}' + api_id = "graphql:https://api.example.com/graphql" + schema_hash = sha256_hex(raw_schema) + existing = _graphql_recipe() + store.save_recipe( + api_id=api_id, + schema_hash=schema_hash, + question="List users", + recipe=existing, + tool_name=existing["public_contract"]["tool_name"], + ) + + candidate = deepcopy(existing) + candidate["public_contract"]["description"] = ( + "Use for listing user identifiers. Returns user ids as CSV. No required params. Do not use for different fields, joins, or workflows." + ) + + async def fake_extract_recipe(**_kwargs): + return candidate - monkeypatch.setattr(store, "save_recipe", save_wrapper) + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) await maybe_extract_and_save_recipe( api_type="graphql", api_id=api_id, question="List users", steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], - sql_steps=[], raw_schema=raw_schema, + learn_rate=1, + original_result=[{"id": 1}], + validate_candidate=lambda _recipe, _args: _async_result([{"id": 1}]), ) - assert called is False - assert len(store.list_recipes(api_id=api_id, schema_hash=schema_hash)) == 1 + recipes = store.list_recipes(api_id=api_id, schema_hash=schema_hash) + assert len(recipes) == 1 + assert recipes[0]["tool_name"] == "list_users" @pytest.mark.asyncio async def test_deduplicate_tool_name_on_collision(monkeypatch): - store = RecipeStore(max_size=10) - monkeypatch.setattr("api_agent.recipe.common.RECIPE_STORE", store) - monkeypatch.setattr("api_agent.recipe.common.settings.ENABLE_RECIPES", True) + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) raw_schema = '{"schema":"ok"}' api_id = "graphql:https://api.example.com/graphql" schema_hash = sha256_hex(raw_schema) recipe_existing = { - "tool_name": "list_users", - "params": {}, - "steps": [ - { - "kind": "graphql", - "name": "users", - "query_template": "{ users { id } }", - } - ], - "sql_steps": [], + **_graphql_recipe(), } store.save_recipe( api_id=api_id, schema_hash=schema_hash, question="List users", recipe=recipe_existing, - tool_name=recipe_existing["tool_name"], + tool_name=recipe_existing["public_contract"]["tool_name"], ) - recipe_new = { + recipe_new = _graphql_recipe() + recipe_new["public_contract"] = { "tool_name": "list_users", - "params": {}, - "steps": [ - { - "kind": "graphql", - "name": "users", - "query_template": "{ users { name } }", - } - ], - "sql_steps": [], + "description": "Use for listing users by name. Returns user names as CSV. No required params. Do not use for different user fields, joins, or workflows.", + "tool_args": {}, + } + recipe_new["execution_plan"] = { + "steps": [_graphql_step("users", "{ users { name } }")], } async def fake_extract_recipe(**_kwargs): return dict(recipe_new) - monkeypatch.setattr("api_agent.recipe.common.extract_recipe", fake_extract_recipe) + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) await maybe_extract_and_save_recipe( api_type="graphql", api_id=api_id, question="List users by name", steps=[{"kind": "graphql", "name": "users", "query": "{ users { name } }"}], - sql_steps=[], raw_schema=raw_schema, + learn_rate=1, + original_result=[{"name": "Ada"}], + validate_candidate=lambda _recipe, _args: _async_result([{"name": "Ada"}]), ) tool_names = { @@ -125,3 +757,573 @@ async def fake_extract_recipe(**_kwargs): } assert "list_users" in tool_names assert "list_users_2" in tool_names + + +@pytest.mark.asyncio +async def test_result_mismatch_rejects_recipe(monkeypatch, caplog): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + caplog.set_level(logging.INFO, logger="api_agent.recipe.learning") + span = SimpleNamespace(name="", attrs={}, events=[]) + + def set_attribute(key, value): + span.attrs[key] = value + + def add_event(name, attrs): + span.events.append((name, attrs)) + + class FakeTraceSpan: + def __enter__(self): + return span + + def __exit__(self, *_args): + return False + + def fake_trace_span(name, attributes=None): + span.name = name + span.attrs.update(attributes or {}) + span.set_attribute = set_attribute + span.add_event = add_event + return FakeTraceSpan() + + async def fake_extract_recipe(**_kwargs): + return _graphql_recipe() + + monkeypatch.setattr("api_agent.recipe.learning.trace_span", fake_trace_span) + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="graphql", + api_id="graphql:https://api.example.com/graphql", + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + raw_schema='{"schema":"ok"}', + learn_rate=1, + original_result=[{"id": 1}], + validate_candidate=lambda _recipe, _args: _async_result([{"id": 2}]), + ) + + assert ( + store.list_recipes( + api_id="graphql:https://api.example.com/graphql", + schema_hash=sha256_hex('{"schema":"ok"}'), + ) + == [] + ) + assert span.name == "recipe.learning" + assert span.attrs["recipe.learning.outcome"] == "skipped" + assert span.attrs["recipe.learning.skip_reason"] == "candidate_result_mismatch" + assert span.attrs["recipe.candidate_rows"] == 1 + assert span.attrs["recipe.expected_rows"] == 1 + event_name, event_attrs = span.events[-1] + assert event_name == "recipe.learning.skipped" + assert event_attrs["recipe.learning.skip_reason"] == "candidate_result_mismatch" + assert ( + "Skipping recipe extraction (candidate result mismatch) candidate_rows=1 expected_rows=1" + ) in caplog.text + + +@pytest.mark.asyncio +async def test_result_mismatch_repairs_and_saves_recipe(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + + bad_recipe = _graphql_recipe() + bad_recipe["execution_plan"]["steps"][0]["call"]["query_template"] = "{ users { name } }" + good_recipe = _graphql_recipe() + extract_calls = [] + + async def fake_extract_recipe(**kwargs): + extract_calls.append(kwargs) + return bad_recipe if len(extract_calls) == 1 else good_recipe + + async def validate_candidate(recipe, _args): + query = recipe["execution_plan"]["steps"][0]["call"]["query_template"] + return [{"id": 1}] if "{ users { id } }" in query else [] + + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="graphql", + api_id="graphql:https://api.example.com/graphql", + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + raw_schema='{"schema":"ok"}', + learn_rate=1, + original_result=[{"id": 1}], + validate_candidate=validate_candidate, + ) + + recipes = store.list_recipes( + api_id="graphql:https://api.example.com/graphql", + schema_hash=sha256_hex('{"schema":"ok"}'), + ) + assert len(recipes) == 1 + assert len(extract_calls) == 2 + assert extract_calls[0]["validation_feedback"] is None + feedback = extract_calls[1]["validation_feedback"] + assert feedback["reason"] == "candidate_result_mismatch" + assert feedback["candidate_result"]["row_count"] == 0 + assert feedback["expected_result"]["row_count"] == 1 + assert feedback["previous_candidate"] == bad_recipe + + +@pytest.mark.asyncio +async def test_large_results_are_sampled_for_extractor_but_validated_fully(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + full_result = [{"id": i} for i in range(8)] + + async def fake_extract_recipe(**kwargs): + assert kwargs["result"] == { + "row_count": 8, + "sample": full_result[:5], + "truncated": True, + } + assert kwargs["steps"][0]["result"] == { + "row_count": 8, + "sample": full_result[:5], + "truncated": True, + } + return _graphql_recipe() + + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="graphql", + api_id="graphql:https://api.example.com/graphql", + question="List users", + steps=[ + { + "kind": "graphql", + "name": "users", + "query": "{ users { id } }", + "result": full_result, + } + ], + raw_schema='{"schema":"ok"}', + learn_rate=1, + original_result=full_result, + validate_candidate=lambda _recipe, _args: _async_result(full_result), + ) + + recipes = store.list_recipes( + api_id="graphql:https://api.example.com/graphql", + schema_hash=sha256_hex('{"schema":"ok"}'), + ) + assert len(recipes) == 1 + assert "validation_fixture" not in recipes[0] + + +@pytest.mark.asyncio +async def test_existing_recipes_context_drops_validation_results(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + + raw_schema = '{"schema":"ok"}' + api_id = "graphql:https://api.example.com/graphql" + existing = _graphql_recipe() + existing["validation_fixture"]["result"] = [{"secret": "do-not-send"}] + store.save_recipe( + api_id=api_id, + schema_hash=sha256_hex(raw_schema), + question="List users", + recipe=existing, + tool_name=existing["public_contract"]["tool_name"], + ) + + async def fake_extract_recipe(**kwargs): + existing_context = kwargs["existing_recipes"] + assert len(existing_context) == 1 + assert "validation_fixture" not in existing_context[0] + assert "execution_plan" not in existing_context[0] + assert "do-not-send" not in json.dumps(existing_context) + assert existing_context[0]["tool_name"] == "list_users" + assert existing_context[0]["public_contract"]["tool_args"] == {} + return _graphql_recipe() + + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="graphql", + api_id=api_id, + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + raw_schema=raw_schema, + learn_rate=1, + original_result=[{"id": 1}], + validate_candidate=lambda _recipe, _args: _async_result([{"id": 1}]), + ) + + +@pytest.mark.asyncio +async def test_repeated_terminal_rest_results_are_validation_baseline(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + + recipe = { + "public_contract": { + "tool_name": "get_team_key_results", + "description": "Use for listing key results for a team in one cycle. Returns key result rows. Requires team and cycle. Do not use for different fields, joins, or workflow.", + "tool_args": { + "team": {"type": "str", "description": "Team"}, + "cycle": {"type": "str", "description": "Cycle"}, + }, + }, + "execution_plan": {"steps": [_rest_step("key_results", "/key-results")]}, + "validation_fixture": {"tool_args": {"team": "alpha-suite", "cycle": "Y2026Q2"}}, + } + expected_result = [{"id": 1}, {"id": 2}, {"id": 3}] + + async def fake_extract_recipe(**kwargs): + assert kwargs["result"] == expected_result + return recipe + + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="rest", + api_id="rest:https://spec|https://api", + question="List alpha-suite key results", + steps=[ + { + "kind": "rest", + "method": "GET", + "path": "/objectives/{objective_id}/key-results", + "path_params": {"objective_id": 1}, + "result": [{"id": 1}], + }, + { + "kind": "rest", + "method": "GET", + "path": "/objectives/{objective_id}/key-results", + "path_params": {"objective_id": 2}, + "result": [{"id": 2}, {"id": 3}], + }, + ], + raw_schema='{"schema":"ok"}', + learn_rate=1, + original_result=[{"id": 2}, {"id": 3}], + validate_candidate=lambda _recipe, _args: _async_result(expected_result), + ) + + assert ( + len( + store.list_recipes( + api_id="rest:https://spec|https://api", + schema_hash=sha256_hex('{"schema":"ok"}'), + ) + ) + == 1 + ) + + +@pytest.mark.asyncio +async def test_empty_intermediate_probe_steps_are_not_sent_to_extractor(monkeypatch, caplog): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + caplog.set_level(logging.INFO, logger="api_agent.recipe.learning") + + recipe = { + "public_contract": { + "tool_name": "get_team_key_results", + "description": "Use for listing key results for a team in one cycle. Returns key result rows. Requires team and cycle. Do not use for different fields, joins, or workflow.", + "tool_args": { + "team": {"type": "str", "description": "Team"}, + "cycle": {"type": "str", "description": "Cycle"}, + }, + }, + "execution_plan": {"steps": [_rest_step("key_results", "/key-results")]}, + "validation_fixture": {"tool_args": {"team": "Example Team", "cycle": "Y2026Q2"}}, + } + expected_result = [{"id": 1}, {"id": 2}] + + async def fake_extract_recipe(**kwargs): + sql_steps = [step for step in kwargs["steps"] if step["kind"] == "sql"] + assert [step["query"] for step in sql_steps] == [ + "SELECT id FROM objectives_q2 WHERE assignee.team.id=1087" + ] + assert kwargs["result"] == expected_result + return recipe + + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="rest", + api_id="rest:https://spec|https://api", + question="List Example Team key results for Q2 2026", + steps=[ + { + "kind": "rest", + "method": "GET", + "path": "/api/v2/objectives", + "query_params": {"cycle": "Y2026Q2"}, + "result": [{"id": 1}], + }, + { + "kind": "sql", + "query": ( + "SELECT id FROM objectives_q2 " + "WHERE lower(assignee.team.name) LIKE '%exampleteam%'" + ), + "result": [], + }, + { + "kind": "rest", + "method": "GET", + "path": "/api/v2/teams", + "result": [{"id": 1087, "name": "Example Team"}], + }, + { + "kind": "sql", + "query": "SELECT id FROM objectives_q2 WHERE assignee.team.id=1087", + "result": [{"id": 22380}, {"id": 22381}], + }, + { + "kind": "rest", + "method": "GET", + "path": "/api/v2/objectives/{objective_id}/key-results", + "path_params": {"objective_id": 22380}, + "result": [{"id": 1}], + }, + { + "kind": "rest", + "method": "GET", + "path": "/api/v2/objectives/{objective_id}/key-results", + "path_params": {"objective_id": 22381}, + "result": [{"id": 2}], + }, + ], + raw_schema='{"schema":"ok"}', + learn_rate=1, + original_result=[{"id": 2}], + validate_candidate=lambda _recipe, _args: _async_result(expected_result), + ) + + assert "Pruned recipe learning dead-end steps count=1" in caplog.text + assert ( + len( + store.list_recipes( + api_id="rest:https://spec|https://api", + schema_hash=sha256_hex('{"schema":"ok"}'), + ) + ) + == 1 + ) + + +@pytest.mark.asyncio +async def test_empty_final_result_can_be_learned(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + + recipe = { + "public_contract": { + "tool_name": "find_team_objectives", + "description": "Use for listing objectives for a team in one cycle. Returns objective rows. Requires team and cycle. Do not use for different fields, joins, or workflow.", + "tool_args": { + "team": {"type": "str", "description": "Team"}, + "cycle": {"type": "str", "description": "Cycle"}, + }, + }, + "execution_plan": {"steps": [_rest_step("objectives", "/api/v2/objectives")]}, + "validation_fixture": {"tool_args": {"team": "No Match", "cycle": "Y2026Q2"}}, + } + + async def fake_extract_recipe(**kwargs): + assert kwargs["result"] == [] + assert kwargs["steps"][-1]["result"] == [] + return recipe + + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="rest", + api_id="rest:https://spec|https://api", + question="List No Match objectives for Q2 2026", + steps=[ + { + "kind": "rest", + "method": "GET", + "path": "/api/v2/objectives", + "query_params": {"cycle": "Y2026Q2"}, + "result": [{"id": 1}], + }, + { + "kind": "sql", + "query": "SELECT id FROM objectives WHERE assignee.team.name = 'No Match'", + "result": [], + }, + ], + raw_schema='{"schema":"ok"}', + learn_rate=1, + original_result=[], + validate_candidate=lambda _recipe, _args: _async_result([]), + ) + + recipes = store.list_recipes( + api_id="rest:https://spec|https://api", + schema_hash=sha256_hex('{"schema":"ok"}'), + ) + assert len(recipes) == 1 + assert "validation_fixture" not in recipes[0] + + +@pytest.mark.asyncio +async def test_result_order_mismatch_rejects_recipe(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + + async def fake_extract_recipe(**_kwargs): + return _graphql_recipe() + + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="graphql", + api_id="graphql:https://api.example.com/graphql", + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + raw_schema='{"schema":"ok"}', + learn_rate=1, + original_result=[{"id": 1}, {"id": 2}], + validate_candidate=lambda _recipe, _args: _async_result([{"id": 2}, {"id": 1}]), + ) + + assert ( + store.list_recipes( + api_id="graphql:https://api.example.com/graphql", + schema_hash=sha256_hex('{"schema":"ok"}'), + ) + == [] + ) + + +@pytest.mark.asyncio +async def test_optimized_candidate_can_use_different_steps_when_result_matches(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + recipe = _graphql_recipe() + recipe["execution_plan"]["steps"] = [_graphql_step("users", "{ users { id name } }")] + + async def fake_extract_recipe(**_kwargs): + return recipe + + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="graphql", + api_id="graphql:https://api.example.com/graphql", + question="List users", + steps=[ + {"kind": "graphql", "name": "users", "query": "{ users { id } }"}, + {"kind": "sql", "query": "SELECT id FROM users"}, + ], + raw_schema='{"schema":"ok"}', + learn_rate=1, + original_result=[{"id": 1}], + validate_candidate=lambda _recipe, _args: _async_result([{"id": 1}]), + ) + + saved = store.list_recipes( + api_id="graphql:https://api.example.com/graphql", + schema_hash=sha256_hex('{"schema":"ok"}'), + ) + assert len(saved) == 1 + assert saved[0]["steps"] == recipe["execution_plan"]["steps"] + + +@pytest.mark.asyncio +async def test_learn_rate_one_overrides_strong_match_skip(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + monkeypatch.setattr("api_agent.recipe.learning.settings.RECIPE_LEARN_RATE", 0.2) + + raw_schema = '{"schema":"ok"}' + api_id = "graphql:https://api.example.com/graphql" + recipe = _graphql_recipe() + called = False + + async def fake_extract_recipe(**_kwargs): + nonlocal called + called = True + return dict(recipe) + + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="graphql", + api_id=api_id, + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + raw_schema=raw_schema, + learn_rate=1, + strong_recipe_match=True, + original_result=[{"id": 1}], + validate_candidate=lambda _recipe, _args: _async_result([{"id": 1}]), + ) + + assert called is True + assert len(store.list_recipes(api_id=api_id, schema_hash=sha256_hex(raw_schema))) == 1 + + +@pytest.mark.asyncio +async def test_strong_match_uses_default_rate_to_skip_extraction(monkeypatch): + store = MemoryApiAgentStore(max_size=10) + monkeypatch.setattr( + "api_agent.recipe.learning.ASYNC_API_AGENT_STORE", AsyncApiAgentStore(store) + ) + monkeypatch.setattr("api_agent.recipe.learning.settings.ENABLE_RECIPES", True) + monkeypatch.setattr("api_agent.recipe.learning.settings.RECIPE_LEARN_RATE", 0.2) + + async def fake_extract_recipe(**_kwargs): + raise AssertionError("extractor should not run") + + monkeypatch.setattr("api_agent.recipe.learning.extract_recipe", fake_extract_recipe) + + await maybe_extract_and_save_recipe( + api_type="graphql", + api_id="graphql:https://api.example.com/graphql", + question="List users", + steps=[{"kind": "graphql", "name": "users", "query": "{ users { id } }"}], + raw_schema='{"schema":"ok"}', + strong_recipe_match=True, + ) + + assert ( + store.list_recipes( + api_id="graphql:https://api.example.com/graphql", + schema_hash=sha256_hex('{"schema":"ok"}'), + ) + == [] + ) diff --git a/tests/test_recipe_runner.py b/tests/test_recipe_runner.py index 0f30ae1..e91c8eb 100644 --- a/tests/test_recipe_runner.py +++ b/tests/test_recipe_runner.py @@ -1,12 +1,34 @@ import pytest from api_agent.context import RequestContext -from api_agent.recipe import build_api_id from api_agent.recipe.runner import execute_recipe_tool -from api_agent.recipe.store import sha256_hex +from api_agent.recipe.search import build_api_id +from api_agent.store import sha256_hex from api_agent.utils.csv import to_csv +def _contract_recipe() -> dict: + return { + "public_contract": { + "tool_name": "get_users", + "description": "Use for listing users. Returns user ids as CSV. No required params. Do not use for different user fields, joins, or workflows.", + "tool_args": {}, + }, + "execution_plan": { + "steps": [ + { + "id": "users", + "kind": "graphql", + "input": {"mode": "single", "with": {}}, + "call": {"query_template": "{ users { id } }"}, + "output": {"name": "users"}, + } + ], + }, + "validation_fixture": {"tool_args": {}}, + } + + @pytest.mark.asyncio async def test_execute_recipe_tool_return_directly_graphql(monkeypatch): ctx = RequestContext( @@ -37,17 +59,20 @@ async def fake_execute_recipe_steps( executed_items_list.append("q1") return True, {"ok": 1}, ["select 1"], "" + async def fake_get_recipe_meta(_recipe_id): + return { + "schema_hash": schema_hash, + "api_id": api_id, + "recipe": _contract_recipe(), + } + monkeypatch.setattr( "api_agent.recipe.runner.load_schema_and_base_url", fake_load_schema_and_base_url ) monkeypatch.setattr("api_agent.recipe.runner.execute_recipe_steps", fake_execute_recipe_steps) monkeypatch.setattr( - "api_agent.recipe.runner.RECIPE_STORE.get_recipe_meta", - lambda _recipe_id: { - "schema_hash": schema_hash, - "api_id": api_id, - "recipe": {"params": {}, "steps": [], "sql_steps": []}, - }, + "api_agent.recipe.runner.ASYNC_API_AGENT_STORE.get_recipe_meta", + fake_get_recipe_meta, ) result = await execute_recipe_tool(ctx, "r_test", params=None, return_directly=True) @@ -84,17 +109,20 @@ async def fake_execute_recipe_steps( executed_items_list.append({"method": "GET", "path": "/users/1"}) return True, {"id": 1}, [], "" + async def fake_get_recipe_meta(_recipe_id): + return { + "schema_hash": schema_hash, + "api_id": api_id, + "recipe": _contract_recipe(), + } + monkeypatch.setattr( "api_agent.recipe.runner.load_schema_and_base_url", fake_load_schema_and_base_url ) monkeypatch.setattr("api_agent.recipe.runner.execute_recipe_steps", fake_execute_recipe_steps) monkeypatch.setattr( - "api_agent.recipe.runner.RECIPE_STORE.get_recipe_meta", - lambda _recipe_id: { - "schema_hash": schema_hash, - "api_id": api_id, - "recipe": {"params": {}, "steps": [], "sql_steps": []}, - }, + "api_agent.recipe.runner.ASYNC_API_AGENT_STORE.get_recipe_meta", + fake_get_recipe_meta, ) result = await execute_recipe_tool(ctx, "r_test", params=None, return_directly=True) diff --git a/tests/test_recipe_store.py b/tests/test_recipe_store.py index b3742e2..0007c50 100644 --- a/tests/test_recipe_store.py +++ b/tests/test_recipe_store.py @@ -1,17 +1,76 @@ -"""Unit tests for recipe store utilities.""" +"""Unit tests for API Agent store utilities.""" import pytest -from api_agent.recipe import ( - RECIPE_STORE, - RecipeStore, - build_recipe_context, - get_example_values, - render_param_refs, - render_text_template, -) -from api_agent.recipe.extractor import _find_used_params, _validate_equivalence -from api_agent.recipe.store import sha256_hex +from api_agent.recipe.contracts import validate_recipe_contract +from api_agent.recipe.execution import render_rest_call_sets +from api_agent.recipe.identity import recipe_fingerprint +from api_agent.recipe.learning import should_learn_recipe +from api_agent.recipe.search import build_recipe_context +from api_agent.recipe.templates import render_param_refs, render_text_template +from api_agent.store import API_AGENT_STORE, MemoryApiAgentStore, sha256_hex + + +def _recipe( + tool_name: str = "test_recipe", + tool_args: dict | None = None, + steps: list | None = None, +) -> dict: + return { + "public_contract": { + "tool_name": tool_name, + "description": "Use for a tested recipe. Returns rows as CSV. No required params. Do not use for different fields, joins, or workflows.", + "tool_args": tool_args or {}, + }, + "execution_plan": { + "steps": steps if steps is not None else [_graphql_step("users", "{ users { id } }")], + }, + "validation_fixture": {"tool_args": {}}, + } + + +def _graphql_step(step_id: str, query_template: str, with_vars: dict | None = None) -> dict: + return { + "id": step_id, + "kind": "graphql", + "input": {"mode": "single", "with": with_vars or {}}, + "call": {"query_template": query_template}, + "output": {"name": step_id}, + } + + +def _sql_step(step_id: str, query_template: str, with_vars: dict | None = None) -> dict: + return { + "id": step_id, + "kind": "sql", + "input": {"mode": "single", "with": with_vars or {}}, + "query_template": query_template, + "output": {"name": step_id}, + } + + +def _rest_step( + step_id: str, + path: str, + *, + input_spec: dict | None = None, + path_params: dict | None = None, + query_params: dict | None = None, + output: dict | None = None, +) -> dict: + return { + "id": step_id, + "kind": "rest", + "input": input_spec or {"mode": "single", "with": {}}, + "call": { + "method": "GET", + "path": path, + "path_params": path_params or {}, + "query_params": query_params or {}, + "body": {}, + }, + "output": output or {"name": step_id}, + } def test_render_text_template_basic(): @@ -26,39 +85,212 @@ def test_sha256_hex_normalizes_json(): assert sha256_hex(a) == sha256_hex(b) +def test_memory_store_caches_downstream_description_by_api_and_schema(): + store = MemoryApiAgentStore(max_size=10) + + store.save_downstream_description( + api_id="rest:https://spec|https://api", + schema_hash="schema-a", + description="Query objectives and key results.", + ) + + assert ( + store.get_downstream_description( + api_id="rest:https://spec|https://api", + schema_hash="schema-a", + ) + == "Query objectives and key results." + ) + assert ( + store.get_downstream_description( + api_id="rest:https://spec|https://api", + schema_hash="schema-b", + ) + is None + ) + + +def test_memory_store_expires_downstream_description(monkeypatch): + now = 1000.0 + monkeypatch.setattr("api_agent.store.time.time", lambda: now) + store = MemoryApiAgentStore(max_size=10) + + store.save_downstream_description( + api_id="rest:https://spec|https://api", + schema_hash="schema-a", + description="Query objectives and key results.", + ttl_seconds=10, + ) + + assert ( + store.get_downstream_description( + api_id="rest:https://spec|https://api", + schema_hash="schema-a", + ) + == "Query objectives and key results." + ) + now = 1011.0 + assert ( + store.get_downstream_description( + api_id="rest:https://spec|https://api", + schema_hash="schema-a", + ) + is None + ) + + def test_render_param_refs_nested(): - obj = {"a": {"$param": "x"}, "b": [{"$param": "y"}], "c": 3} + obj = {"a": {"$var": "x"}, "b": [{"$var": "y"}], "c": 3} out = render_param_refs(obj, {"x": 1, "y": "foo"}) assert out == {"a": 1, "b": ["foo"], "c": 3} -def test_get_example_values(): - spec = {"limit": {"type": "int", "default": 10}, "q": {"type": "str"}} - params = get_example_values(spec, {"q": "abc"}) - assert params == {"limit": 10, "q": "abc"} +def test_render_rest_call_sets_maps_binding_rows(): + step = _rest_step( + "key_results", + "/objectives/{id}/key-results", + input_spec={ + "mode": "map", + "from": "objectives", + "bind": {"objective_id": "id"}, + "with": {"cycle": {"value": "cycle"}}, + }, + path_params={"id": {"$var": "objective_id"}}, + query_params={"cycle": {"$var": "cycle"}}, + ) + + rendered, error = render_rest_call_sets( + step, + {"cycle": "Y2026Q2"}, + {"objectives": [{"id": 10}, {"id": 11}]}, + ) + + assert error == "" + assert [(r.path_params, r.query_params, r.body, r.binding) for r in rendered] == [ + ({"id": 10}, {"cycle": "Y2026Q2"}, None, {"objective_id": 10}), + ({"id": 11}, {"cycle": "Y2026Q2"}, None, {"objective_id": 11}), + ] + + +def test_render_rest_call_sets_zips_fields_from_one_binding_rowset(): + step = _rest_step( + "key_results", + "/objectives/{id}/key-results", + input_spec={ + "mode": "map", + "from": "objective_owner_pairs", + "bind": {"objective_id": "id", "owner_id": "owner_id"}, + }, + path_params={"id": {"$var": "objective_id"}}, + query_params={"owner": {"$var": "owner_id"}}, + ) + + rendered, error = render_rest_call_sets( + step, + {}, + {"objective_owner_pairs": [{"id": 10, "owner_id": 20}, {"id": 11, "owner_id": 21}]}, + ) + + assert error == "" + assert [(r.path_params, r.query_params, r.binding) for r in rendered] == [ + ({"id": 10}, {"owner": 20}, {"objective_id": 10, "owner_id": 20}), + ({"id": 11}, {"owner": 21}, {"objective_id": 11, "owner_id": 21}), + ] + + +def test_render_rest_call_sets_batches_binding_rows(): + step = _rest_step( + "key_results", + "/key-results", + input_spec={ + "mode": "batch", + "from": "objectives", + "bind": {"objective_ids": "id"}, + }, + query_params={"ids": {"$var": "objective_ids"}}, + ) + + rendered, error = render_rest_call_sets( + step, + {}, + {"objectives": [{"id": 10}, {"id": 11}]}, + ) + + assert error == "" + assert [(r.query_params, r.binding) for r in rendered] == [ + ({"ids": [10, 11]}, {"objective_ids": [10, 11]}) + ] + + +def test_validate_recipe_contract_rejects_missing_binding_rowset(): + step = _rest_step( + "key_results", + "/objectives/{id}/key-results", + input_spec={"mode": "map", "from": "objectives", "bind": {"objective_id": "id"}}, + path_params={"objective_id": {"$var": "objective_id"}}, + ) + recipe = _recipe( + steps=[step], + ) + + assert validate_recipe_contract(recipe, "rest") == "step input must reference prior output" + + +def test_validate_recipe_contract_rejects_duplicate_step_output(): + recipe = _recipe( + steps=[ + _rest_step("users", "/users", output={"name": "rows"}), + _rest_step("posts", "/posts", output={"name": "rows"}), + ], + ) + + assert validate_recipe_contract(recipe, "rest") == "duplicate step output" + + +def test_validate_recipe_contract_rejects_batch_output_binding_attachment(): + recipe = _recipe( + steps=[ + _rest_step("objectives", "/objectives"), + _rest_step( + "key_results", + "/key-results", + input_spec={ + "mode": "batch", + "from": "objectives", + "bind": {"objective_ids": "id"}, + }, + query_params={"ids": {"$var": "objective_ids"}}, + output={"name": "key_results", "attach_binding": ["objective_ids"]}, + ), + ], + ) + + assert validate_recipe_contract(recipe, "rest") == "invalid output binding" + +def test_render_rest_call_sets_allows_empty_map(): + step = _rest_step( + "key_results", + "/objectives/{id}/key-results", + input_spec={"mode": "map", "from": "objectives", "bind": {"objective_id": "id"}}, + path_params={"id": {"$var": "objective_id"}}, + ) + rendered, error = render_rest_call_sets(step, {}, {"objectives": []}) -def test_get_example_values_none_value(): - """None defaults are included (not skipped).""" - spec = {"id": {"type": "int", "default": None}, "limit": {"type": "int", "default": 10}} - params = get_example_values(spec, {}) - assert params == {"id": None, "limit": 10} - # Provided value overrides None default - params2 = get_example_values(spec, {"id": 42}) - assert params2 == {"id": 42, "limit": 10} + assert error == "" + assert rendered == [] def test_recipe_store_preserves_defaults(): """Defaults are preserved as-is (no sensitivity filtering).""" - store = RecipeStore(max_size=10) - recipe = { - "params": { + store = MemoryApiAgentStore(max_size=10) + recipe = _recipe( + tool_args={ "user_id": {"type": "str", "default": "123e4567-e89b-12d3-a456-426614174000"}, "limit": {"type": "int", "default": 10}, }, - "steps": [], - "sql_steps": [], - } + steps=[], + ) recipe_id = store.save_recipe( api_id="rest:https://spec|https://api", schema_hash="s", @@ -69,14 +301,128 @@ def test_recipe_store_preserves_defaults(): saved = store.get_recipe(recipe_id) assert saved is not None # Defaults preserved exactly as provided - assert saved["params"]["user_id"]["default"] == "123e4567-e89b-12d3-a456-426614174000" - assert saved["params"]["limit"]["default"] == 10 + assert saved["public_contract"]["tool_args"]["user_id"]["default"] == ( + "123e4567-e89b-12d3-a456-426614174000" + ) + assert saved["public_contract"]["tool_args"]["limit"]["default"] == 10 + + +def test_recipe_store_save_is_idempotent_by_fingerprint(): + store = MemoryApiAgentStore(max_size=10) + recipe = _recipe(steps=[_graphql_step("users", "{ users { id } }")]) + + first_id = store.save_recipe( + api_id="graphql:https://api.example.com/graphql", + schema_hash="s", + question="list users", + recipe=recipe, + tool_name="list_users", + ) + second_id = store.save_recipe( + api_id="graphql:https://api.example.com/graphql", + schema_hash="s", + question="list users again", + recipe=recipe, + tool_name="list_users_duplicate", + ) + + assert second_id == first_id + assert ( + len(store.list_recipes(api_id="graphql:https://api.example.com/graphql", schema_hash="s")) + == 1 + ) + + +def test_recipe_store_fifo_eviction_does_not_promote_duplicates(): + store = MemoryApiAgentStore(max_size=2) + api_id = "graphql:https://api.example.com/graphql" + recipes = [ + _recipe(tool_name="a", steps=[_graphql_step("a", "{ a }")]), + _recipe(tool_name="b", steps=[_graphql_step("b", "{ b }")]), + _recipe(tool_name="c", steps=[_graphql_step("c", "{ c }")]), + ] + + first_id = store.save_recipe( + api_id=api_id, schema_hash="s", question="a", recipe=recipes[0], tool_name="a" + ) + store.save_recipe( + api_id=api_id, schema_hash="s", question="a again", recipe=recipes[0], tool_name="a" + ) + second_id = store.save_recipe( + api_id=api_id, schema_hash="s", question="b", recipe=recipes[1], tool_name="b" + ) + third_id = store.save_recipe( + api_id=api_id, schema_hash="s", question="c", recipe=recipes[2], tool_name="c" + ) + + ids = {r["recipe_id"] for r in store.list_recipes(api_id=api_id, schema_hash="s")} + assert first_id not in ids + assert ids == {second_id, third_id} + + +def test_recipe_store_disable_hides_recipe(): + store = MemoryApiAgentStore(max_size=10) + api_id = "rest:https://spec|https://api" + recipe = _recipe(tool_name="list_users", steps=[_rest_step("users", "/users")]) + recipe_id = store.save_recipe( + api_id=api_id, schema_hash="s", question="users", recipe=recipe, tool_name="list_users" + ) + + assert store.disable_recipe(recipe_id) + assert store.list_recipes(api_id=api_id, schema_hash="s") == [] + assert ( + store.list_recipes(api_id=api_id, schema_hash="s", include_disabled=True)[0]["enabled"] + is False + ) + + +def test_recipe_fingerprint_normalizes_whitespace(): + left = { + **_recipe( + steps=[ + _graphql_step("users", "{ users { id } }"), + _sql_step("filtered_users", "SELECT * FROM users"), + ] + ) + } + right = { + **_recipe( + steps=[ + _graphql_step("users", "{ users { id } }"), + _sql_step("filtered_users", "SELECT * FROM users"), + ] + ) + } + + assert recipe_fingerprint( + api_id="graphql:x", schema_hash="s", recipe=left + ) == recipe_fingerprint(api_id="graphql:x", schema_hash="s", recipe=right) + + +def test_recipe_fingerprint_ignores_generated_copy_and_name(): + left = _recipe(tool_name="list_users") + right = _recipe(tool_name="fetch_users") + right["public_contract"]["description"] = ( + "Use for fetching user ids. Returns rows as CSV. No required params. Do not use for other workflows." + ) + + assert recipe_fingerprint( + api_id="graphql:x", schema_hash="s", recipe=left + ) == recipe_fingerprint(api_id="graphql:x", schema_hash="s", recipe=right) + + +def test_should_learn_recipe_rates(): + assert not should_learn_recipe(api_id="a", schema_hash="s", question="q", learn_rate=0) + assert should_learn_recipe(api_id="a", schema_hash="s", question="q", learn_rate=1) + assert should_learn_recipe( + api_id="a", schema_hash="s", question="q", learn_rate=0.5 + ) == should_learn_recipe(api_id="a", schema_hash="s", question="q", learn_rate=0.5) def test_recipe_store_scoring_prefers_closer_match(): - store = RecipeStore(max_size=10) - r1 = {"params": {}, "steps": [], "sql_steps": []} - r2 = {"params": {}, "steps": [], "sql_steps": []} + store = MemoryApiAgentStore(max_size=10) + r1 = _recipe(tool_name="top_hotels", steps=[]) + r2 = _recipe(tool_name="list_users", steps=[]) id1 = store.save_recipe( api_id="rest:a|b", schema_hash="s", @@ -100,9 +446,9 @@ def test_recipe_store_scoring_prefers_closer_match(): def test_recipe_store_scoring_handles_token_order(): - store = RecipeStore(max_size=10) - r1 = {"params": {}, "steps": [], "sql_steps": []} - r2 = {"params": {}, "steps": [], "sql_steps": []} + store = MemoryApiAgentStore(max_size=10) + r1 = _recipe(tool_name="find_hotels_in_nyc", steps=[]) + r2 = _recipe(tool_name="find_users_in_nyc", steps=[]) id1 = store.save_recipe( api_id="rest:a|b", schema_hash="s", @@ -133,7 +479,7 @@ def test_render_text_template_missing_param_raises(): def test_validate_recipe_params_requires_all_params(): """All declared params are required (defaults are examples, not fallbacks).""" - from api_agent.recipe.common import validate_recipe_params + from api_agent.recipe.execution import validate_recipe_params params_spec = { "manager_name": {"type": "str", "default": "Alice Smith"}, @@ -146,7 +492,7 @@ def test_validate_recipe_params_requires_all_params(): def test_validate_recipe_params_rejects_extra(): """Extra params are rejected even when spec is non-empty.""" - from api_agent.recipe.common import validate_recipe_params + from api_agent.recipe.execution import validate_recipe_params params_spec = {"limit": {"type": "int", "default": 10}} params, error = validate_recipe_params(params_spec, {"limit": 5, "extra": "bad"}) @@ -154,163 +500,27 @@ def test_validate_recipe_params_rejects_extra(): assert "unexpected params: extra" in error -def test_global_recipe_store_available(): - # Basic smoke test to ensure singleton is constructed - assert RECIPE_STORE is not None - - -# --- Extractor tests --- - - -def test_find_used_params_graphql(): - recipe = { - "params": {"limit": {"default": 10}}, - "steps": [{"kind": "graphql", "query_template": "{ users(limit: {{limit}}) { id } }"}], - "sql_steps": [], - } - assert _find_used_params(recipe, "graphql") == {"limit"} - - -def test_find_used_params_sql(): - recipe = { - "params": {"prefix": {"default": "A"}}, - "steps": [], - "sql_steps": ["SELECT * FROM t WHERE name ILIKE '{{prefix}}%'"], - } - assert _find_used_params(recipe, "graphql") == {"prefix"} - - -def test_find_used_params_rest_param_refs(): - recipe = { - "params": {"id": {"default": "123"}}, - "steps": [ - { - "kind": "rest", - "method": "GET", - "path": "/users/{id}", - "path_params": {"id": {"$param": "id"}}, - "query_params": {}, - "body": {}, - } - ], - "sql_steps": [], - } - assert _find_used_params(recipe, "rest") == {"id"} - - -def test_find_used_params_multiple(): - recipe = { - "params": {"limit": {}, "prefix": {}}, - "steps": [{"kind": "graphql", "query_template": "{ users(limit: {{limit}}) }"}], - "sql_steps": ["SELECT * WHERE name ILIKE '{{prefix}}%'"], - } - assert _find_used_params(recipe, "graphql") == {"limit", "prefix"} +def test_validate_recipe_params_enforces_declared_types(): + from api_agent.recipe.execution import validate_recipe_params - -def test_find_used_params_none_used(): - recipe = { - "params": {"unused": {"default": "x"}}, - "steps": [{"kind": "graphql", "query_template": "{ users { id } }"}], - "sql_steps": ["SELECT * FROM t"], - } - assert _find_used_params(recipe, "graphql") == set() - - -def test_validate_equivalence_graphql_valid(): - original_steps = [{"kind": "graphql", "query": "{ users(limit: 10) { id } }", "name": "data"}] - original_sql = ["SELECT * FROM data WHERE active = true"] - recipe = { - "params": {"limit": {"type": "int", "default": 10}}, - "steps": [ - { - "kind": "graphql", - "query_template": "{ users(limit: {{limit}}) { id } }", - "name": "data", - } - ], - "sql_steps": ["SELECT * FROM data WHERE active = true"], - } - assert _validate_equivalence( - api_type="graphql", original_steps=original_steps, original_sql=original_sql, recipe=recipe - ) - - -def test_validate_equivalence_graphql_mismatch(): - original_steps = [{"kind": "graphql", "query": "{ users(limit: 10) { id } }", "name": "data"}] - original_sql = [] - recipe = { - "params": {"limit": {"type": "int", "default": 5}}, # Wrong default - "steps": [ - { - "kind": "graphql", - "query_template": "{ users(limit: {{limit}}) { id } }", - "name": "data", - } - ], - "sql_steps": [], - } - assert not _validate_equivalence( - api_type="graphql", original_steps=original_steps, original_sql=original_sql, recipe=recipe - ) + params_spec = {"limit": {"type": "int", "description": "Limit"}} + params, error = validate_recipe_params(params_spec, {"limit": "ten"}) + assert params is None + assert "invalid param type: limit must be int" in error -def test_validate_equivalence_sql_parameterized(): - original_steps = [{"kind": "graphql", "query": "{ teams { name } }", "name": "data"}] - original_sql = ["SELECT * FROM data WHERE name ILIKE 'A%'"] - recipe = { - "params": {"prefix": {"type": "str", "default": "A"}}, - "steps": [{"kind": "graphql", "query_template": "{ teams { name } }", "name": "data"}], - "sql_steps": ["SELECT * FROM data WHERE name ILIKE '{{prefix}}%'"], - } - assert _validate_equivalence( - api_type="graphql", original_steps=original_steps, original_sql=original_sql, recipe=recipe - ) +def test_validate_recipe_params_coerces_declared_types(): + from api_agent.recipe.execution import validate_recipe_params - -def test_validate_equivalence_rest_valid(): - original_steps = [ - { - "kind": "rest", - "method": "GET", - "path": "/users", - "name": "data", - "path_params": {}, - "query_params": {"limit": 10}, - "body": {}, - } - ] - original_sql = [] - recipe = { - "params": {"limit": {"type": "int", "default": 10}}, - "steps": [ - { - "kind": "rest", - "method": "GET", - "path": "/users", - "name": "data", - "path_params": {}, - "query_params": {"limit": {"$param": "limit"}}, - "body": {}, - } - ], - "sql_steps": [], - } - assert _validate_equivalence( - api_type="rest", original_steps=original_steps, original_sql=original_sql, recipe=recipe - ) + params_spec = {"limit": {"type": "int", "description": "Limit"}} + params, error = validate_recipe_params(params_spec, {"limit": "10"}) + assert error == "" + assert params == {"limit": 10} -def test_validate_equivalence_length_mismatch(): - original_steps = [{"kind": "graphql", "query": "{ a }", "name": "a"}] - original_sql = [] - recipe = { - "params": {}, - "steps": [], # Empty - length mismatch - "sql_steps": [], - } - assert not _validate_equivalence( - api_type="graphql", original_steps=original_steps, original_sql=original_sql, recipe=recipe - ) +def test_global_recipe_store_available(): + # Basic smoke test to ensure singleton is constructed + assert API_AGENT_STORE is not None def test_build_recipe_context_empty(): @@ -318,19 +528,44 @@ def test_build_recipe_context_empty(): assert build_recipe_context([]) == "" +def _recipe_suggestion( + *, + recipe_id: str, + score: float, + question: str, + recipe: dict, + params: dict | None = None, + tool_name: str | None = None, +) -> dict: + suggestion = { + "recipe_id": recipe_id, + "score": score, + "question": question, + "params": params or {}, + "recipe": recipe, + } + if tool_name: + suggestion["tool_name"] = tool_name + return suggestion + + def test_build_recipe_context_with_suggestions(): """Suggestions are formatted correctly for prompt injection.""" - r1 = {"params": {"prefix": {"type": "str", "default": "A"}}, "steps": [], "sql_steps": []} - r2 = {"params": {}, "steps": [], "sql_steps": []} + r1 = _recipe( + tool_name="get_users_starting_with_a", + tool_args={"prefix": {"type": "str", "description": "Prefix"}}, + steps=[], + ) + r2 = _recipe(tool_name="list_all_users", steps=[]) - rid1 = RECIPE_STORE.save_recipe( + rid1 = API_AGENT_STORE.save_recipe( api_id="rest:test|test", schema_hash="s", question="get users starting with A", recipe=r1, tool_name="get_users_starting_with_a", ) - rid2 = RECIPE_STORE.save_recipe( + rid2 = API_AGENT_STORE.save_recipe( api_id="rest:test|test", schema_hash="s", question="list all users", @@ -339,20 +574,21 @@ def test_build_recipe_context_with_suggestions(): ) suggestions = [ - { - "recipe_id": rid1, - "score": 0.85, - "question": "get users starting with A", - "params": {"prefix": {"type": "str", "default": "A"}}, - "tool_name": "get_users_starting_with_a", - }, - { - "recipe_id": rid2, - "score": 0.72, - "question": "list all users", - "params": {}, - "tool_name": "list_all_users", - }, + _recipe_suggestion( + recipe_id=rid1, + score=0.85, + question="get users starting with A", + params={"prefix": {"type": "str", "description": "Prefix"}}, + recipe=r1, + tool_name="get_users_starting_with_a", + ), + _recipe_suggestion( + recipe_id=rid2, + score=0.72, + question="list all users", + recipe=r2, + tool_name="list_all_users", + ), ] result = build_recipe_context(suggestions) @@ -360,15 +596,15 @@ def test_build_recipe_context_with_suggestions(): assert "" in result assert "Score: 0.85" in result assert "get users starting with A" in result - assert "prefix: str = A" in result + assert "prefix: str" in result assert "Score: 0.72" in result assert "list all users" in result def test_build_recipe_context_no_params(): """Recipes without params show empty param list.""" - r = {"params": {}, "steps": [], "sql_steps": []} - rid = RECIPE_STORE.save_recipe( + r = _recipe(tool_name="simple_query", steps=[]) + rid = API_AGENT_STORE.save_recipe( api_id="rest:test|test", schema_hash="s", question="simple query", @@ -377,7 +613,7 @@ def test_build_recipe_context_no_params(): ) suggestions = [ - {"recipe_id": rid, "score": 0.90, "question": "simple query", "params": {}}, + _recipe_suggestion(recipe_id=rid, score=0.90, question="simple query", recipe=r), ] result = build_recipe_context(suggestions) # Tool name with no params should have empty signature @@ -387,13 +623,16 @@ def test_build_recipe_context_no_params(): def test_build_recipe_context_enhanced_format(): """Enhanced context shows tool names, score hints, step summaries.""" # Create mock recipe in store - # Using global RECIPE_STORE - recipe = { - "params": {"user_id": {"type": "int", "default": 123}}, - "steps": [{"kind": "rest", "method": "GET", "path": "/users"}], - "sql_steps": ["SELECT * FROM data WHERE active = true"], - } - recipe_id = RECIPE_STORE.save_recipe( + # Using global API_AGENT_STORE + recipe = _recipe( + tool_name="get_users_recent_posts", + tool_args={"user_id": {"type": "int", "description": "User id"}}, + steps=[ + _rest_step("users", "/users"), + _sql_step("active_users", "SELECT * FROM users WHERE active = true"), + ], + ) + recipe_id = API_AGENT_STORE.save_recipe( api_id="rest:test|test", schema_hash="test_hash", question="Get user's recent posts", @@ -402,12 +641,13 @@ def test_build_recipe_context_enhanced_format(): ) suggestions = [ - { - "recipe_id": recipe_id, - "score": 0.85, - "question": "Get user's recent posts", - "params": {"user_id": {"type": "int", "default": 123}}, - } + _recipe_suggestion( + recipe_id=recipe_id, + score=0.85, + question="Get user's recent posts", + params={"user_id": {"type": "int", "description": "User id"}}, + recipe=recipe, + ) ] result = build_recipe_context(suggestions) @@ -415,7 +655,7 @@ def test_build_recipe_context_enhanced_format(): # Check new format elements assert "Available recipe tools" in result assert "get_users_recent_posts" in result # Sanitized tool name - assert "user_id: int = 123" in result # Typed param signature + assert "user_id: int" in result # Typed param signature assert "Score: 0.85" in result assert "STRONG MATCH" in result # Score >= 0.8 assert "1 API call + 1 SQL step" in result # Step summary @@ -423,11 +663,11 @@ def test_build_recipe_context_enhanced_format(): def test_build_recipe_context_score_hints(): """Test different score interpretation hints.""" - # Using global RECIPE_STORE - recipe = {"params": {}, "steps": [], "sql_steps": []} + # Using global API_AGENT_STORE + recipe = _recipe(steps=[]) # High score - rid1 = RECIPE_STORE.save_recipe( + rid1 = API_AGENT_STORE.save_recipe( api_id="rest:a|b", schema_hash="s", question="high score query", @@ -435,7 +675,7 @@ def test_build_recipe_context_score_hints(): tool_name="high_score_query", ) # Medium score - rid2 = RECIPE_STORE.save_recipe( + rid2 = API_AGENT_STORE.save_recipe( api_id="rest:a|b", schema_hash="s", question="medium score query", @@ -443,7 +683,7 @@ def test_build_recipe_context_score_hints(): tool_name="medium_score_query", ) # Low score - rid3 = RECIPE_STORE.save_recipe( + rid3 = API_AGENT_STORE.save_recipe( api_id="rest:a|b", schema_hash="s", question="low score query", @@ -452,9 +692,11 @@ def test_build_recipe_context_score_hints(): ) suggestions = [ - {"recipe_id": rid1, "score": 0.92, "question": "high score query", "params": {}}, - {"recipe_id": rid2, "score": 0.68, "question": "medium score query", "params": {}}, - {"recipe_id": rid3, "score": 0.45, "question": "low score query", "params": {}}, + _recipe_suggestion(recipe_id=rid1, score=0.92, question="high score query", recipe=recipe), + _recipe_suggestion( + recipe_id=rid2, score=0.68, question="medium score query", recipe=recipe + ), + _recipe_suggestion(recipe_id=rid3, score=0.45, question="low score query", recipe=recipe), ] result = build_recipe_context(suggestions) @@ -466,43 +708,47 @@ def test_build_recipe_context_score_hints(): def test_build_recipe_context_step_summaries(): """Test step summary formatting.""" - # Using global RECIPE_STORE + # Using global API_AGENT_STORE # API only - r1 = {"params": {}, "steps": [{"kind": "rest"}], "sql_steps": []} + r1 = _recipe(tool_name="api_only", steps=[_rest_step("users", "/users")]) # SQL only - r2 = {"params": {}, "steps": [], "sql_steps": ["SELECT * FROM t"]} + r2 = _recipe(tool_name="sql_only", steps=[_sql_step("rows", "SELECT * FROM t")]) # Both - r3 = { - "params": {}, - "steps": [{"kind": "rest"}, {"kind": "rest"}], - "sql_steps": ["SQL1", "SQL2"], - } + r3 = _recipe( + tool_name="both", + steps=[ + _rest_step("users", "/users"), + _rest_step("posts", "/posts"), + _sql_step("sql1", "SQL1"), + _sql_step("sql2", "SQL2"), + ], + ) # Neither - r4 = {"params": {}, "steps": [], "sql_steps": []} + r4 = _recipe(tool_name="neither", steps=[]) - rid1 = RECIPE_STORE.save_recipe( + rid1 = API_AGENT_STORE.save_recipe( api_id="rest:a|b", schema_hash="s", question="api only", recipe=r1, tool_name="api_only", ) - rid2 = RECIPE_STORE.save_recipe( + rid2 = API_AGENT_STORE.save_recipe( api_id="rest:a|b", schema_hash="s", question="sql only", recipe=r2, tool_name="sql_only", ) - rid3 = RECIPE_STORE.save_recipe( + rid3 = API_AGENT_STORE.save_recipe( api_id="rest:a|b", schema_hash="s", question="both", recipe=r3, tool_name="both", ) - rid4 = RECIPE_STORE.save_recipe( + rid4 = API_AGENT_STORE.save_recipe( api_id="rest:a|b", schema_hash="s", question="neither", @@ -511,10 +757,10 @@ def test_build_recipe_context_step_summaries(): ) suggestions = [ - {"recipe_id": rid1, "score": 0.7, "question": "api only", "params": {}}, - {"recipe_id": rid2, "score": 0.7, "question": "sql only", "params": {}}, - {"recipe_id": rid3, "score": 0.7, "question": "both", "params": {}}, - {"recipe_id": rid4, "score": 0.7, "question": "neither", "params": {}}, + _recipe_suggestion(recipe_id=rid1, score=0.7, question="api only", recipe=r1), + _recipe_suggestion(recipe_id=rid2, score=0.7, question="sql only", recipe=r2), + _recipe_suggestion(recipe_id=rid3, score=0.7, question="both", recipe=r3), + _recipe_suggestion(recipe_id=rid4, score=0.7, question="neither", recipe=r4), ] result = build_recipe_context(suggestions) @@ -525,21 +771,29 @@ def test_build_recipe_context_step_summaries(): assert "no steps" in result -def test_validate_and_prepare_recipe_success(): - """validate_and_prepare_recipe returns recipe and params.""" +@pytest.mark.asyncio +async def test_validate_and_prepare_recipe_success(): + """async_validate_and_prepare_recipe returns recipe and params.""" from contextvars import ContextVar - from api_agent.recipe import RECIPE_STORE, validate_and_prepare_recipe + from api_agent.recipe.learning import async_validate_and_prepare_recipe + from api_agent.store import API_AGENT_STORE schema_var: ContextVar[str] = ContextVar("schema") schema_var.set('{"type": "test"}') - recipe = { - "params": {"limit": {"type": "int", "default": 10}}, - "steps": [{"kind": "graphql", "query_template": "{ users }"}], - "sql_steps": [], - } - rid = RECIPE_STORE.save_recipe( + recipe = _recipe( + tool_name="get_users", + tool_args={"limit": {"type": "int", "description": "Limit"}}, + steps=[ + _graphql_step( + "users", + "{ users(limit: {{limit}}) { id } }", + {"limit": {"value": "limit"}}, + ) + ], + ) + rid = API_AGENT_STORE.save_recipe( api_id="graphql:test", schema_hash="abc", question="get users", @@ -547,36 +801,38 @@ def test_validate_and_prepare_recipe_success(): tool_name="get_users", ) - result, params, error = validate_and_prepare_recipe(rid, '{"limit": 5}', schema_var) + result, params, error = await async_validate_and_prepare_recipe(rid, '{"limit": 5}', schema_var) assert error == "" assert result is not None assert params == {"limit": 5} -def test_validate_and_prepare_recipe_not_found(): - """validate_and_prepare_recipe returns error for missing recipe.""" +@pytest.mark.asyncio +async def test_validate_and_prepare_recipe_not_found(): + """async_validate_and_prepare_recipe returns error for missing recipe.""" from contextvars import ContextVar - from api_agent.recipe import validate_and_prepare_recipe + from api_agent.recipe.learning import async_validate_and_prepare_recipe schema_var: ContextVar[str] = ContextVar("schema") schema_var.set('{"type": "test"}') - result, params, error = validate_and_prepare_recipe("nonexistent", "{}", schema_var) + result, params, error = await async_validate_and_prepare_recipe("nonexistent", "{}", schema_var) assert result is None assert params is None assert "not found" in error -def test_validate_and_prepare_recipe_no_schema(): - """validate_and_prepare_recipe returns error when schema not loaded.""" +@pytest.mark.asyncio +async def test_validate_and_prepare_recipe_no_schema(): + """async_validate_and_prepare_recipe returns error when schema not loaded.""" from contextvars import ContextVar - from api_agent.recipe import validate_and_prepare_recipe + from api_agent.recipe.learning import async_validate_and_prepare_recipe schema_var: ContextVar[str] = ContextVar("schema") # Not set - result, params, error = validate_and_prepare_recipe("r_123", "{}", schema_var) + result, params, error = await async_validate_and_prepare_recipe("r_123", "{}", schema_var) assert result is None assert "schema not loaded" in error @@ -586,17 +842,19 @@ async def test_execute_recipe_steps_returns_executed_sql(): """execute_recipe_steps returns executed SQL list.""" from contextvars import ContextVar - from api_agent.recipe.common import execute_recipe_steps + from api_agent.recipe.execution import execute_recipe_steps query_results: ContextVar[dict] = ContextVar("qr") last_result: ContextVar[list] = ContextVar("lr") query_results.set({"data": [{"id": 1, "name": "test"}]}) last_result.set([None]) - recipe = { - "steps": [], - "sql_steps": ["SELECT * FROM data", "SELECT id FROM data WHERE id = 1"], - } + recipe = _recipe( + steps=[ + _sql_step("data_rows", "SELECT * FROM data"), + _sql_step("first_data_row", "SELECT id FROM data WHERE id = 1"), + ], + ) executed_items: list = [] @@ -624,17 +882,19 @@ async def test_execute_recipe_steps_with_api_and_sql(): """execute_recipe_steps executes both API and SQL steps.""" from contextvars import ContextVar - from api_agent.recipe.common import execute_recipe_steps + from api_agent.recipe.execution import execute_recipe_steps query_results: ContextVar[dict] = ContextVar("qr") last_result: ContextVar[list] = ContextVar("lr") query_results.set({}) last_result.set([None]) - recipe = { - "steps": [{"kind": "test", "name": "step1"}], - "sql_steps": ["SELECT * FROM step1"], - } + recipe = _recipe( + steps=[ + {"kind": "test", "name": "step1"}, + _sql_step("step1_rows", "SELECT * FROM step1"), + ], + ) executed_items: list = [] executor_calls: list = [] @@ -660,22 +920,123 @@ async def mock_executor(idx, step, params, results): assert last_data == [{"id": 1}, {"id": 2}] # SQL result +@pytest.mark.asyncio +async def test_execute_recipe_steps_preserves_interleaved_order(): + """SQL steps can run between API steps and expose named SQL results.""" + from contextvars import ContextVar + + from api_agent.recipe.execution import execute_recipe_steps + + query_results: ContextVar[dict] = ContextVar("qr") + last_result: ContextVar[list] = ContextVar("lr") + query_results.set({}) + last_result.set([None]) + + recipe = _recipe( + steps=[ + {"kind": "test", "name": "source"}, + _sql_step("filtered", "SELECT id FROM source WHERE id = 2"), + {"kind": "test", "name": "after_sql"}, + ], + ) + + executed_items: list = [] + executor_calls: list = [] + + async def mock_executor(idx, step, params, results): + executor_calls.append(step["name"]) + if step["name"] == "source": + results["source"] = [{"id": 1}, {"id": 2}] + return True, results["source"], "", {"call": "source"} + assert results["filtered"] == [{"id": 2}] + results["after_sql"] = [{"ok": True}] + return True, results["after_sql"], "", {"call": "after_sql"} + + success, last_data, executed_sql, error = await execute_recipe_steps( + recipe, + {}, + query_results, + last_result, + mock_executor, + executed_items, + ) + + assert success is True + assert error == "" + assert executor_calls == ["source", "after_sql"] + assert executed_items == [{"call": "source"}, {"call": "after_sql"}] + assert executed_sql == ["SELECT id FROM source WHERE id = 2"] + assert last_data == [{"ok": True}] + + +@pytest.mark.asyncio +async def test_execute_recipe_steps_allows_mapped_step_executor_to_resolve_bindings(): + from contextvars import ContextVar + + from api_agent.recipe.execution import build_step_input_sets, execute_recipe_steps + + query_results: ContextVar[dict] = ContextVar("qr") + last_result: ContextVar[list] = ContextVar("lr") + query_results.set({}) + last_result.set([None]) + + mapped_step = { + "kind": "test", + "name": "key_results", + "input": { + "mode": "map", + "from": "filtered_objectives", + "bind": {"objective_id": "id"}, + }, + } + recipe = _recipe( + steps=[ + {"kind": "test", "name": "objectives"}, + _sql_step("filtered_objectives", "SELECT id FROM objectives WHERE id > 1 ORDER BY id"), + mapped_step, + ], + ) + + executor_params: list[dict] = [] + + async def mock_executor(_idx, step, params, results): + if step["name"] == "objectives": + results["objectives"] = [{"id": 1}, {"id": 2}, {"id": 3}] + return True, results["objectives"], "", {"call": "objectives"} + input_sets, input_error = build_step_input_sets(step, params, results) + assert input_error == "" + executor_params.extend([input_set.params for input_set in input_sets]) + return True, [{"objective_id": 2}, {"objective_id": 3}], "", {"call": "key_results"} + + success, last_data, executed_sql, error = await execute_recipe_steps( + recipe, + {}, + query_results, + last_result, + mock_executor, + [], + ) + + assert success is True + assert error == "" + assert executed_sql == ["SELECT id FROM objectives WHERE id > 1 ORDER BY id"] + assert executor_params == [{"objective_id": 2}, {"objective_id": 3}] + assert last_data == [{"objective_id": 2}, {"objective_id": 3}] + + @pytest.mark.asyncio async def test_execute_recipe_steps_api_failure(): """execute_recipe_steps returns empty sql on API failure.""" from contextvars import ContextVar - from api_agent.recipe.common import execute_recipe_steps + from api_agent.recipe.execution import execute_recipe_steps query_results: ContextVar[dict] = ContextVar("qr") last_result: ContextVar[list] = ContextVar("lr") query_results.set({}) last_result.set([None]) - recipe = { - "steps": [{"kind": "test"}], - "sql_steps": ["SELECT * FROM data"], - } + recipe = _recipe(steps=[{"kind": "test"}]) async def failing_executor(idx, step, params, results): return False, None, '{"error": "api failed"}', None @@ -694,6 +1055,42 @@ async def failing_executor(idx, step, params, results): assert "api failed" in error +@pytest.mark.asyncio +async def test_execute_recipe_steps_api_failure_after_sql_keeps_executed_sql(): + from contextvars import ContextVar + + from api_agent.recipe.execution import execute_recipe_steps + + query_results: ContextVar[dict] = ContextVar("qr") + last_result: ContextVar[list] = ContextVar("lr") + query_results.set({"data": [{"id": 1}]}) + last_result.set([None]) + + recipe = _recipe( + steps=[ + _sql_step("data_ids", "SELECT id FROM data"), + {"kind": "test"}, + ], + ) + + async def failing_executor(idx, step, params, results): + return False, None, '{"error": "api failed"}', None + + success, last_data, executed_sql, error = await execute_recipe_steps( + recipe, + {}, + query_results, + last_result, + failing_executor, + [], + ) + + assert success is False + assert last_data is None + assert executed_sql == ["SELECT id FROM data"] + assert "api failed" in error + + # --- sanitize_tool_name tests --- @@ -706,7 +1103,7 @@ def test_basic(self): def test_special_chars_stripped(self): from api_agent.recipe.naming import sanitize_tool_name - assert sanitize_tool_name("get-users!@#v2") == "getusersv2" + assert sanitize_tool_name("get-users!@#beta") == "get_users_beta" def test_spaces_to_underscores(self): from api_agent.recipe.naming import sanitize_tool_name @@ -729,117 +1126,13 @@ def test_leading_trailing_underscores_stripped(self): assert sanitize_tool_name(" _hello_ ") == "hello" + def test_reserved_prefixes_removed(self): + from api_agent.recipe.naming import sanitize_tool_name -# --- _recipes_equivalent tests --- - - -class TestRecipesEquivalent: - def test_identical_graphql(self): - from api_agent.recipe.common import _recipes_equivalent - - r = { - "params": {"limit": {"type": "int"}}, - "steps": [{"kind": "graphql", "name": "users", "query_template": "{ users }"}], - "sql_steps": ["SELECT * FROM users"], - } - assert _recipes_equivalent(r, dict(r), "graphql") is True - - def test_whitespace_normalized_graphql(self): - from api_agent.recipe.common import _recipes_equivalent - - a = { - "params": {}, - "steps": [{"kind": "graphql", "name": "d", "query_template": "{ users\n{ id } }"}], - "sql_steps": [], - } - b = { - "params": {}, - "steps": [{"kind": "graphql", "name": "d", "query_template": "{ users { id } }"}], - "sql_steps": [], - } - assert _recipes_equivalent(a, b, "graphql") is True - - def test_different_params(self): - from api_agent.recipe.common import _recipes_equivalent - - a = {"params": {"x": {"type": "int"}}, "steps": [], "sql_steps": []} - b = {"params": {"y": {"type": "int"}}, "steps": [], "sql_steps": []} - assert _recipes_equivalent(a, b, "graphql") is False - - def test_different_step_count(self): - from api_agent.recipe.common import _recipes_equivalent - - a = { - "params": {}, - "steps": [{"kind": "graphql", "name": "a", "query_template": "q"}], - "sql_steps": [], - } - b = {"params": {}, "steps": [], "sql_steps": []} - assert _recipes_equivalent(a, b, "graphql") is False - - def test_rest_step_fields(self): - from api_agent.recipe.common import _recipes_equivalent - - base = { - "kind": "rest", - "name": "d", - "method": "GET", - "path": "/a", - "path_params": {}, - "query_params": {}, - "body": {}, - } - a = {"params": {}, "steps": [base], "sql_steps": []} - b_step = {**base, "path": "/b"} - b = {"params": {}, "steps": [b_step], "sql_steps": []} - assert _recipes_equivalent(a, b, "rest") is False - - def test_sql_whitespace_normalized(self): - from api_agent.recipe.common import _recipes_equivalent - - a = {"params": {}, "steps": [], "sql_steps": ["SELECT * FROM t"]} - b = {"params": {}, "steps": [], "sql_steps": ["SELECT * FROM t"]} - assert _recipes_equivalent(a, b, "graphql") is True - - -# --- recipe change tracking tests --- - - -class TestRecipeChangeTracking: - def test_consume_empty(self): - from api_agent.recipe.common import consume_recipe_changes, reset_recipe_change_flag - - reset_recipe_change_flag() - assert consume_recipe_changes() == [] - - def test_mark_and_consume(self): - from api_agent.recipe.common import ( - consume_recipe_changes, - mark_recipe_changed, - reset_recipe_change_flag, - ) - - reset_recipe_change_flag() - mark_recipe_changed("r_1") - mark_recipe_changed("r_2") - changes = consume_recipe_changes() - assert changes == ["r_1", "r_2"] - # second consume should be empty - assert consume_recipe_changes() == [] - - def test_consume_without_reset(self): - """consume_recipe_changes works even without prior reset.""" - import contextvars - - from api_agent.recipe.common import consume_recipe_changes, mark_recipe_changed - - # Force fresh ContextVar state by operating in a new context - - ctx = contextvars.copy_context() + assert sanitize_tool_name("r_list_users") == "list_users" + assert sanitize_tool_name("api_get_users") == "get_users" - def _inner(): - mark_recipe_changed("r_x") - return consume_recipe_changes() + def test_digit_prefix_gets_safe_slug(self): + from api_agent.recipe.naming import sanitize_tool_name - result = ctx.run(_inner) - assert result == ["r_x"] + assert sanitize_tool_name("123 hotels") == "recipe_123_hotels" diff --git a/tests/test_recipe_tool_creation.py b/tests/test_recipe_tool_creation.py deleted file mode 100644 index 9686fec..0000000 --- a/tests/test_recipe_tool_creation.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Test dynamic recipe tool creation with Pydantic models.""" - -import pytest -from pydantic import ValidationError - -from api_agent.recipe import create_params_model - - -def test_create_params_model_basic(): - """Test create_params_model creates dynamic model with strict config.""" - params_spec = { - "user_id": {"type": "int", "default": 123}, - "query": {"type": "str", "default": "test"}, - } - - ParamsModel = create_params_model(params_spec, "Test") - - # Test: Model can be instantiated with valid params - instance = ParamsModel(user_id=456, query="custom") - assert instance.user_id == 456 - assert instance.query == "custom" - - # Test: All fields required (no defaults -- stored defaults are examples only) - with pytest.raises(ValidationError): - ParamsModel() - - # Test: Extra fields rejected (strict mode) - with pytest.raises(ValidationError): - ParamsModel(user_id=456, extra_field="should_fail") - - # Test: Model has correct schema (no additionalProperties) - schema = ParamsModel.model_json_schema() - assert "additionalProperties" not in schema or schema.get("additionalProperties") is False - assert sorted(schema.get("required", [])) == ["query", "user_id"] - - -def test_create_params_model_dynamic_name(): - """Test dynamic function names work correctly.""" - params_spec = {"param1": {"type": "str", "default": "value"}} - ParamsModel = create_params_model(params_spec, "CustomTool") - - # Create function with dynamic name - async def dynamic_tool(params: ParamsModel) -> str: - return f"Called with {params.param1}" - - tool_name = "my_custom_recipe_tool" - dynamic_tool.__name__ = tool_name - assert dynamic_tool.__name__ == tool_name - - -def test_create_params_model_multiple(): - """Test creating multiple dynamic models doesn't conflict.""" - # Create first model - Model1 = create_params_model({"field_a": {"type": "str", "default": "a"}}, "Recipe1") - - # Create second model - Model2 = create_params_model({"field_b": {"type": "int", "default": 1}}, "Recipe2") - - # Both should work independently - inst1 = Model1(field_a="custom") - inst2 = Model2(field_b=99) - - assert inst1.field_a == "custom" - assert inst2.field_b == 99 - - # Each should enforce its own schema - with pytest.raises(ValidationError): - Model1(field_b=1) # wrong field - - with pytest.raises(ValidationError): - Model2(field_a="a") # wrong field - - -def test_create_params_model_sdk_compatibility(): - """Test generated schema is compatible with OpenAI Agents SDK (no additionalProperties).""" - params_spec = { - "startsWith": {"type": "str", "default": "A"}, - "limit": {"type": "int", "default": 10}, - } - - ParamsModel = create_params_model(params_spec, "list_managers_starting_with") - - schema = ParamsModel.model_json_schema() - - # Critical: OpenAI SDK rejects schemas with additionalProperties: true - assert "additionalProperties" not in schema or schema.get("additionalProperties") is False - - # Verify schema structure - assert schema.get("type") == "object" - assert "properties" in schema - assert "startsWith" in schema["properties"] - assert "limit" in schema["properties"] - - # All fields required (no defaults) - assert sorted(schema.get("required", [])) == ["limit", "startsWith"] - with pytest.raises(ValidationError): - ParamsModel() - - -def test_create_params_model_all_types(): - """Test create_params_model handles all supported types.""" - params_spec = { - "str_param": {"type": "str", "default": "text"}, - "int_param": {"type": "int", "default": 42}, - "float_param": {"type": "float", "default": 3.14}, - "bool_param": {"type": "bool", "default": True}, - } - - ParamsModel = create_params_model(params_spec, "AllTypes") - instance = ParamsModel(str_param="text", int_param=42, float_param=3.14, bool_param=True) - - assert instance.str_param == "text" - assert instance.int_param == 42 - assert instance.float_param == 3.14 - assert instance.bool_param is True - - # All required - with pytest.raises(ValidationError): - ParamsModel() - - -def test_create_params_model_unknown_type_defaults_to_str(): - """Test unknown type falls back to str.""" - params_spec = {"param": {"type": "unknown", "default": "value"}} - ParamsModel = create_params_model(params_spec, "UnknownType") - - instance = ParamsModel(param="value") - assert instance.param == "value" - assert isinstance(instance.param, str) - - # Still required - with pytest.raises(ValidationError): - ParamsModel() diff --git a/tests/test_recipe_tool_integration.py b/tests/test_recipe_tool_integration.py deleted file mode 100644 index b6314a9..0000000 --- a/tests/test_recipe_tool_integration.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Integration tests for recipe tool registration with agent.""" - -from unittest.mock import patch - -import pytest - -from api_agent.agent.graphql_agent import _create_individual_recipe_tools as graphql_create_tools -from api_agent.agent.rest_agent import _create_individual_recipe_tools as rest_create_tools -from api_agent.context import RequestContext - - -@pytest.fixture -def mock_context(): - """Create a mock request context.""" - return RequestContext( - target_url="https://test.api.com/graphql", - target_headers={}, - api_type="graphql", - base_url=None, - include_result=False, - allow_unsafe_paths=(), - poll_paths=(), - ) - - -@pytest.fixture -def sample_recipe_suggestions(): - """Sample recipe suggestions with parameters.""" - return [ - { - "recipe_id": "r_test123", - "question": "List managers starting with B", - "tool_name": "list_managers_starting_with_b", - "params": {"startsWith": {"type": "str", "default": "B"}}, - "steps": [{"kind": "graphql", "query_template": "{ users { name } }"}], - "sql_steps": [], - } - ] - - -def test_graphql_create_tools_basic(mock_context, sample_recipe_suggestions): - """Test that recipe tools are created successfully.""" - - with patch("api_agent.agent.graphql_agent.RECIPE_STORE") as mock_store: - # Mock the recipe store to return our test recipe - mock_store.get_recipe.return_value = { - "params": sample_recipe_suggestions[0]["params"], - "steps": sample_recipe_suggestions[0]["steps"], - "sql_steps": sample_recipe_suggestions[0]["sql_steps"], - } - - # Create recipe tools - tools = graphql_create_tools(mock_context, sample_recipe_suggestions) - - # Verify tools were created - assert len(tools) == 1 - tool = tools[0] - - # Verify tool has correct name (FunctionTool has .name attribute) - assert tool.name == "list_managers_starting_with_b" - - # Verify tool is a FunctionTool - from agents import FunctionTool - - assert isinstance(tool, FunctionTool) - - -def test_create_multiple_recipe_tools(mock_context): - """Test creating multiple recipe tools with different params.""" - - suggestions = [ - { - "recipe_id": "r_recipe1", - "question": "Get users by role", - "tool_name": "get_users_by_role", - "params": {"role": {"type": "str", "default": "admin"}}, - "steps": [{"kind": "graphql"}], - "sql_steps": [], - }, - { - "recipe_id": "r_recipe2", - "question": "List teams with limit", - "tool_name": "list_teams_with_limit", - "params": {"limit": {"type": "int", "default": 10}}, - "steps": [{"kind": "graphql"}], - "sql_steps": [], - }, - ] - - with patch("api_agent.agent.graphql_agent.RECIPE_STORE") as mock_store: - # Mock recipe store for both recipes - def get_recipe(recipe_id): - for s in suggestions: - if s["recipe_id"] == recipe_id: - return { - "params": s["params"], - "steps": s["steps"], - "sql_steps": s["sql_steps"], - } - return None - - mock_store.get_recipe.side_effect = get_recipe - - # Create tools - tools = graphql_create_tools(mock_context, suggestions) - - # Verify both tools created - assert len(tools) == 2 - - # Verify tool names are different - tool_names = [t.name for t in tools] - assert "get_users_by_role" in tool_names - assert "list_teams_with_limit" in tool_names - assert len(set(tool_names)) == 2 # All unique - - -def test_recipe_tool_has_correct_signature(mock_context, sample_recipe_suggestions): - """Test that recipe tool has correct parameter signature.""" - - with patch("api_agent.agent.graphql_agent.RECIPE_STORE") as mock_store: - mock_store.get_recipe.return_value = { - "params": sample_recipe_suggestions[0]["params"], - "steps": sample_recipe_suggestions[0]["steps"], - "sql_steps": sample_recipe_suggestions[0]["sql_steps"], - } - - tools = graphql_create_tools(mock_context, sample_recipe_suggestions) - tool = tools[0] - - # Verify tool has strict JSON schema enabled - # This ensures OpenAI Agents SDK compatibility (no additionalProperties) - assert tool.strict_json_schema is True - - # Verify tool name is correct - assert tool.name == "list_managers_starting_with_b" - - -def test_recipe_tool_without_params(mock_context): - """Test creating recipe tool with no parameters.""" - - suggestions = [ - { - "recipe_id": "r_noparams", - "question": "Get all users", - "tool_name": "get_all_users", - "params": {}, # No params - "steps": [{"kind": "graphql"}], - "sql_steps": [], - } - ] - - with patch("api_agent.agent.graphql_agent.RECIPE_STORE") as mock_store: - mock_store.get_recipe.return_value = { - "params": {}, - "steps": suggestions[0]["steps"], - "sql_steps": suggestions[0]["sql_steps"], - } - - # Should still create tool successfully - tools = graphql_create_tools(mock_context, suggestions) - assert len(tools) == 1 - assert tools[0].name == "get_all_users" - - -def test_recipe_tool_name_deduplication(mock_context): - """Test that duplicate tool names get numbered suffixes.""" - - suggestions = [ - { - "recipe_id": "r_dup1", - "question": "Get users", - "tool_name": "get_users", - "params": {}, - "steps": [{"kind": "graphql"}], - "sql_steps": [], - }, - { - "recipe_id": "r_dup2", - "question": "Get users different", - "tool_name": "get_users", # Same name - "params": {}, - "steps": [{"kind": "graphql"}], - "sql_steps": [], - }, - ] - - with patch("api_agent.agent.graphql_agent.RECIPE_STORE") as mock_store: - - def get_recipe(recipe_id): - for s in suggestions: - if s["recipe_id"] == recipe_id: - return {"params": s["params"], "steps": s["steps"], "sql_steps": s["sql_steps"]} - return None - - mock_store.get_recipe.side_effect = get_recipe - - tools = graphql_create_tools(mock_context, suggestions) - - # Should have 2 tools with unique names - assert len(tools) == 2 - tool_names = [t.name for t in tools] - assert "get_users" in tool_names - assert "get_users_2" in tool_names # Second one gets _2 suffix - - -def test_recipe_tool_return_directly_default(mock_context, sample_recipe_suggestions): - """Test that recipe tools have return_directly=True by default.""" - - with patch("api_agent.agent.graphql_agent.RECIPE_STORE") as mock_store: - mock_store.get_recipe.return_value = { - "params": sample_recipe_suggestions[0]["params"], - "steps": sample_recipe_suggestions[0]["steps"], - "sql_steps": sample_recipe_suggestions[0]["sql_steps"], - } - - tools = graphql_create_tools(mock_context, sample_recipe_suggestions) - tool = tools[0] - - # Check the schema has return_directly with default=True - schema = tool.params_json_schema - return_directly_param = schema["properties"]["return_directly"] - assert return_directly_param["default"] is True - # Verify required params are visible in description - desc = tool.description.lower() - assert "required" in desc - - # Verify tool name - assert tool.name == "list_managers_starting_with_b" - - -# REST Agent Integration Tests - - -@pytest.fixture -def rest_context(): - """Create a REST request context.""" - return RequestContext( - target_url="https://test.api.com", - target_headers={}, - api_type="rest", - base_url="/v1", - include_result=False, - allow_unsafe_paths=(), - poll_paths=(), - ) - - -@pytest.fixture -def rest_recipe_suggestions(): - """Sample REST recipe suggestions.""" - return [ - { - "recipe_id": "r_rest123", - "question": "Get user by ID", - "tool_name": "get_user_by_id", - "params": {"user_id": {"type": "int", "default": 1}}, - "steps": [{"kind": "rest", "method": "GET", "path": "/users/{{user_id}}"}], - "sql_steps": [], - } - ] - - -def test_rest_create_tools_basic(rest_context, rest_recipe_suggestions): - """Test REST recipe tools are created successfully.""" - with patch("api_agent.agent.rest_agent.RECIPE_STORE") as mock_store: - mock_store.get_recipe.return_value = { - "params": rest_recipe_suggestions[0]["params"], - "steps": rest_recipe_suggestions[0]["steps"], - "sql_steps": rest_recipe_suggestions[0]["sql_steps"], - } - - tools = rest_create_tools(rest_context, "/v1", rest_recipe_suggestions) - - assert len(tools) == 1 - tool = tools[0] - assert tool.name == "get_user_by_id" - - from agents import FunctionTool - - assert isinstance(tool, FunctionTool) - - -def test_rest_create_multiple_tools(rest_context): - """Test creating multiple REST recipe tools.""" - suggestions = [ - { - "recipe_id": "r_rest1", - "question": "List users", - "tool_name": "list_users", - "params": {"limit": {"type": "int", "default": 10}}, - "steps": [{"kind": "rest"}], - "sql_steps": [], - }, - { - "recipe_id": "r_rest2", - "question": "Get user posts", - "tool_name": "get_user_posts", - "params": {"user_id": {"type": "int", "default": 1}}, - "steps": [{"kind": "rest"}], - "sql_steps": [], - }, - ] - - with patch("api_agent.agent.rest_agent.RECIPE_STORE") as mock_store: - - def get_recipe(recipe_id): - for s in suggestions: - if s["recipe_id"] == recipe_id: - return {"params": s["params"], "steps": s["steps"], "sql_steps": s["sql_steps"]} - return None - - mock_store.get_recipe.side_effect = get_recipe - - tools = rest_create_tools(rest_context, "/v1", suggestions) - - assert len(tools) == 2 - tool_names = [t.name for t in tools] - assert "list_users" in tool_names - assert "get_user_posts" in tool_names - - -def test_rest_tool_strict_schema(rest_context, rest_recipe_suggestions): - """Test REST recipe tools have strict JSON schema.""" - with patch("api_agent.agent.rest_agent.RECIPE_STORE") as mock_store: - mock_store.get_recipe.return_value = { - "params": rest_recipe_suggestions[0]["params"], - "steps": rest_recipe_suggestions[0]["steps"], - "sql_steps": rest_recipe_suggestions[0]["sql_steps"], - } - - tools = rest_create_tools(rest_context, "/v1", rest_recipe_suggestions) - tool = tools[0] - - assert tool.strict_json_schema is True - assert tool.name == "get_user_by_id" - - -def test_rest_tool_name_deduplication(rest_context): - """Test REST recipe tool name deduplication.""" - suggestions = [ - { - "recipe_id": "r_dup1", - "question": "Get data", - "tool_name": "get_data", - "params": {}, - "steps": [{"kind": "rest"}], - "sql_steps": [], - }, - { - "recipe_id": "r_dup2", - "question": "Get data v2", - "tool_name": "get_data", # Same name - "params": {}, - "steps": [{"kind": "rest"}], - "sql_steps": [], - }, - ] - - with patch("api_agent.agent.rest_agent.RECIPE_STORE") as mock_store: - - def get_recipe(recipe_id): - for s in suggestions: - if s["recipe_id"] == recipe_id: - return {"params": s["params"], "steps": s["steps"], "sql_steps": s["sql_steps"]} - return None - - mock_store.get_recipe.side_effect = get_recipe - - tools = rest_create_tools(rest_context, "/v1", suggestions) - - assert len(tools) == 2 - tool_names = [t.name for t in tools] - assert "get_data" in tool_names - assert "get_data_2" in tool_names - - -def test_rest_tool_return_directly_default(rest_context, rest_recipe_suggestions): - """Test REST recipe tools have return_directly=True by default.""" - with patch("api_agent.agent.rest_agent.RECIPE_STORE") as mock_store: - mock_store.get_recipe.return_value = { - "params": rest_recipe_suggestions[0]["params"], - "steps": rest_recipe_suggestions[0]["steps"], - "sql_steps": rest_recipe_suggestions[0]["sql_steps"], - } - - tools = rest_create_tools(rest_context, "/v1", rest_recipe_suggestions) - tool = tools[0] - - schema = tool.params_json_schema - return_directly_param = schema["properties"]["return_directly"] - assert return_directly_param["default"] is True diff --git a/tests/test_redis_recipe_store.py b/tests/test_redis_recipe_store.py new file mode 100644 index 0000000..13be593 --- /dev/null +++ b/tests/test_redis_recipe_store.py @@ -0,0 +1,209 @@ +import threading + +import pytest + +from api_agent.store import AsyncApiAgentStore, RedisApiAgentStore + + +class FakePipeline: + def __init__(self, client): + self.client = client + self.calls = [] + + def set(self, *args): + self.calls.append(("set", args)) + return self + + def sadd(self, *args): + self.calls.append(("sadd", args)) + return self + + def rpush(self, *args): + self.calls.append(("rpush", args)) + return self + + def delete(self, *args): + self.calls.append(("delete", args)) + return self + + def srem(self, *args): + self.calls.append(("srem", args)) + return self + + def lrem(self, *args): + self.calls.append(("lrem", args)) + return self + + def execute(self): + for name, args in self.calls: + getattr(self.client, name)(*args) + + +class FakeRedis: + def __init__(self): + self.values = {} + self.expiries = {} + self.sets = {} + self.lists = {} + self.counts = {} + + def pipeline(self): + return FakePipeline(self) + + def get(self, key): + return self.values.get(key) + + def set(self, key, value, ex=None): + self.values[key] = value + if ex is not None: + self.expiries[key] = ex + + def incr(self, key): + self.counts[key] = self.counts.get(key, 0) + 1 + return self.counts[key] + + def sadd(self, key, value): + self.sets.setdefault(key, set()).add(value) + + def smembers(self, key): + return set(self.sets.get(key, set())) + + def srem(self, key, value): + self.sets.setdefault(key, set()).discard(value) + + def scard(self, key): + return len(self.sets.get(key, set())) + + def rpush(self, key, value): + self.lists.setdefault(key, []).append(value) + + def lpop(self, key): + values = self.lists.setdefault(key, []) + return values.pop(0) if values else None + + def lrem(self, key, _count, value): + self.lists[key] = [v for v in self.lists.get(key, []) if v != value] + + def delete(self, key): + self.values.pop(key, None) + + +def _recipe(tool_name: str, query_template: str) -> dict: + return { + "public_contract": { + "tool_name": tool_name, + "description": f"Use for {tool_name}. Returns rows as CSV. No required params.", + "tool_args": {}, + }, + "execution_plan": { + "steps": [ + { + "id": tool_name, + "kind": "graphql", + "input": {"mode": "single", "with": {}}, + "call": {"query_template": query_template}, + "output": {"name": tool_name}, + } + ], + }, + "validation_fixture": {"tool_args": {}}, + } + + +def _redis_store(monkeypatch) -> tuple[RedisApiAgentStore, FakeRedis]: + fake = FakeRedis() + + import redis + + monkeypatch.setattr(redis.Redis, "from_url", staticmethod(lambda *_args, **_kwargs: fake)) + return RedisApiAgentStore("redis://test", namespace="test", max_size=2), fake + + +def test_redis_recipe_store_idempotent_and_fifo(monkeypatch): + store, _fake = _redis_store(monkeypatch) + api_id = "graphql:https://api.example.com/graphql" + recipes = [ + _recipe("a", "{ a }"), + _recipe("b", "{ b }"), + _recipe("c", "{ c }"), + ] + + first_id = store.save_recipe( + api_id=api_id, schema_hash="s", question="a", recipe=recipes[0], tool_name="a" + ) + assert ( + store.save_recipe( + api_id=api_id, schema_hash="s", question="a again", recipe=recipes[0], tool_name="a" + ) + == first_id + ) + second_id = store.save_recipe( + api_id=api_id, schema_hash="s", question="b", recipe=recipes[1], tool_name="b" + ) + third_id = store.save_recipe( + api_id=api_id, schema_hash="s", question="c", recipe=recipes[2], tool_name="c" + ) + + ids = {r["recipe_id"] for r in store.list_recipes(api_id=api_id, schema_hash="s")} + assert ids == {second_id, third_id} + + +def test_redis_recipe_store_caches_downstream_description(monkeypatch): + store, _fake = _redis_store(monkeypatch) + + store.save_downstream_description( + api_id="rest:https://spec|https://api", + schema_hash="schema-a", + description="Query objectives and key results.", + ) + + assert ( + store.get_downstream_description( + api_id="rest:https://spec|https://api", + schema_hash="schema-a", + ) + == "Query objectives and key results." + ) + assert ( + store.get_downstream_description( + api_id="rest:https://spec|https://api", + schema_hash="schema-b", + ) + is None + ) + + +def test_redis_recipe_store_sets_downstream_description_ttl(monkeypatch): + store, fake = _redis_store(monkeypatch) + + store.save_downstream_description( + api_id="rest:https://spec|https://api", + schema_hash="schema-a", + description="Query objectives and key results.", + ttl_seconds=300, + ) + + key = store._downstream_description_key("rest:https://spec|https://api", "schema-a") + assert fake.expiries[key] == 300 + + +@pytest.mark.asyncio +async def test_redis_recipe_store_calls_are_offloaded(monkeypatch): + store, _fake = _redis_store(monkeypatch) + event_loop_thread_id = threading.get_ident() + + def list_recipes(*, api_id, schema_hash, include_disabled=False): + return [ + { + "api_id": api_id, + "schema_hash": schema_hash, + "include_disabled": include_disabled, + "thread_id": threading.get_ident(), + } + ] + + monkeypatch.setattr(store, "list_recipes", list_recipes) + + result = await AsyncApiAgentStore(store).list_recipes(api_id="api", schema_hash="schema") + + assert result[0]["thread_id"] != event_loop_thread_id diff --git a/tests/test_rest_client.py b/tests/test_rest_client.py index 6233646..8a72ebe 100644 --- a/tests/test_rest_client.py +++ b/tests/test_rest_client.py @@ -5,77 +5,7 @@ import httpx import pytest -from api_agent.rest.client import _build_url, _is_path_allowed, execute_request - - -class TestIsPathAllowed: - """Test path allowlist matching.""" - - def test_exact_match(self): - assert _is_path_allowed("/search", ["/search"]) is True - - def test_no_match(self): - assert _is_path_allowed("/users", ["/search"]) is False - - def test_glob_star(self): - assert _is_path_allowed("/api/v1/search", ["/api/*/search"]) is True - assert _is_path_allowed("/api/v2/search", ["/api/*/search"]) is True - assert _is_path_allowed("/api/search", ["/api/*/search"]) is False - - def test_multiple_patterns(self): - patterns = ["/search", "/_search", "/api/*/query"] - assert _is_path_allowed("/search", patterns) is True - assert _is_path_allowed("/_search", patterns) is True - assert _is_path_allowed("/api/v1/query", patterns) is True - assert _is_path_allowed("/users", patterns) is False - - def test_empty_patterns(self): - assert _is_path_allowed("/search", []) is False - - def test_nested_wildcard_pattern_matching(self): - """Verify nested wildcard patterns match expected paths.""" - pattern = "/api/booking/search/*" - - # Should match - assert _is_path_allowed("/api/booking/search/v1/hotels", [pattern]) is True - assert _is_path_allowed("/api/booking/search/anything", [pattern]) is True - - # Should NOT match - assert _is_path_allowed("/api/booking/search", [pattern]) is False - assert _is_path_allowed("/api/booking/other", [pattern]) is False - assert _is_path_allowed("/api/other/search/v1", [pattern]) is False - - -class TestBuildUrl: - """Test URL building.""" - - def test_simple_path(self): - url = _build_url("/users", base_url="https://api.example.com") - assert url == "https://api.example.com/users" - - def test_path_params(self): - url = _build_url( - "/users/{id}", base_url="https://api.example.com", path_params={"id": "123"} - ) - assert url == "https://api.example.com/users/123" - - def test_query_params(self): - url = _build_url( - "/users", base_url="https://api.example.com", query_params={"limit": 10, "offset": 0} - ) - assert "limit=10" in url - assert "offset=0" in url - - def test_query_params_filters_none(self): - url = _build_url( - "/users", base_url="https://api.example.com", query_params={"limit": 10, "offset": None} - ) - assert "limit=10" in url - assert "offset" not in url - - def test_no_base_url_raises(self): - with pytest.raises(ValueError, match="No base URL provided"): - _build_url("/users", base_url="") +from api_agent.rest.client import _build_url, execute_request class TestExecuteRequest: @@ -134,6 +64,15 @@ async def test_no_base_url_returns_error(self): assert result["success"] is False assert "No base URL" in result["error"] + def test_list_query_params_use_repeated_keys(self): + url = _build_url( + "/key-results", + "https://api.example.com", + query_params={"ids": [10, 11], "cycle": "Y2026Q2"}, + ) + + assert url == "https://api.example.com/key-results?ids=10&ids=11&cycle=Y2026Q2" + @pytest.mark.asyncio async def test_post_allowed_with_matching_path(self): # POST is allowed when path matches allow_unsafe_paths diff --git a/tests/test_rest_schema.py b/tests/test_rest_schema.py index 58165e5..30614d8 100644 --- a/tests/test_rest_schema.py +++ b/tests/test_rest_schema.py @@ -1,25 +1,14 @@ """Tests for REST/OpenAPI schema context generation.""" -import json -from unittest.mock import AsyncMock, MagicMock, patch - -import httpx import pytest from api_agent.rest.schema_loader import ( - _rewrite_swagger_ref, - _swagger_param_to_oas3, - _swagger_request_body_to_oas3, - _swagger_responses_to_oas3, - _swagger_security_to_oas3, - _swagger_servers_from_spec, _format_params, _format_schema, _infer_string_format, + _normalize_swagger2_to_oas3, _schema_to_type, build_schema_context, - load_openapi_spec, - normalize_swagger2_to_oas3, ) @@ -387,6 +376,8 @@ def test_post_endpoint_optional_body(self): class TestSwagger2Normalization: + """Test Swagger 2.0 to OAS3 normalization.""" + def test_normalize_swagger2_basic_shapes(self): swagger_spec = { "swagger": "2.0", @@ -396,7 +387,9 @@ def test_normalize_swagger2_basic_shapes(self): "schemes": ["https"], "paths": { "/users/{id}": { - "parameters": [{"name": "id", "in": "path", "required": True, "type": "string"}], + "parameters": [ + {"name": "id", "in": "path", "required": True, "type": "string"} + ], "get": { "summary": "Get user", "responses": {"200": {"schema": {"$ref": "#/definitions/User"}}}, @@ -430,7 +423,7 @@ def test_normalize_swagger2_basic_shapes(self): "securityDefinitions": {"basicAuth": {"type": "basic"}}, } - normalized = normalize_swagger2_to_oas3(swagger_spec) + normalized = _normalize_swagger2_to_oas3(swagger_spec) assert normalized["openapi"].startswith("3.") assert normalized["servers"] == [{"url": "https://api.example.com/v1"}] @@ -460,7 +453,9 @@ def test_build_context_from_normalized_swagger2(self): "paths": { "/users/{id}": { "get": { - "parameters": [{"name": "id", "in": "path", "required": True, "type": "string"}], + "parameters": [ + {"name": "id", "in": "path", "required": True, "type": "string"} + ], "responses": {"200": {"schema": {"$ref": "#/definitions/User"}}}, } } @@ -473,75 +468,7 @@ def test_build_context_from_normalized_swagger2(self): } }, } - normalized = normalize_swagger2_to_oas3(swagger_spec) + normalized = _normalize_swagger2_to_oas3(swagger_spec) ctx = build_schema_context(normalized) assert "GET /users/{id}(id: str) -> User" in ctx assert "User { id: str! }" in ctx - - -class TestSwagger2Helpers: - def test_rewrite_swagger_ref(self): - assert _rewrite_swagger_ref("#/definitions/User") == "#/components/schemas/User" - - def test_swagger_param_conversion(self): - result = _swagger_param_to_oas3({"name": "limit", "in": "query", "type": "integer"}) - assert result is not None - assert result["schema"]["type"] == "integer" - - def test_swagger_request_body_extraction(self): - body, remaining = _swagger_request_body_to_oas3( - [ - {"in": "body", "name": "data", "required": True, "schema": {"type": "object"}}, - {"in": "query", "name": "limit", "type": "integer"}, - ] - ) - assert body is not None - assert body["required"] is True - assert len(remaining) == 1 - - def test_swagger_response_conversion(self): - responses = {"200": {"description": "ok", "schema": {"$ref": "#/definitions/User"}}} - result = _swagger_responses_to_oas3(responses) - assert result["200"]["content"]["application/json"]["schema"]["$ref"] == ( - "#/components/schemas/User" - ) - - def test_swagger_security_conversion(self): - result = _swagger_security_to_oas3({"basicAuth": {"type": "basic"}}) - assert result["basicAuth"] == {"type": "http", "scheme": "basic"} - - def test_swagger_servers_from_spec(self): - result = _swagger_servers_from_spec( - {"host": "api.example.com", "basePath": "/v1", "schemes": ["https"]} - ) - assert result == [{"url": "https://api.example.com/v1"}] - - -def _mock_http_response(status: int, text: str): - mock_resp = MagicMock(spec=httpx.Response) - mock_resp.status_code = status - mock_resp.text = text - if status >= 400: - mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( - "error", request=MagicMock(), response=mock_resp - ) - else: - mock_resp.raise_for_status.return_value = None - return mock_resp - - -def _patch_http(text: str, status: int = 200): - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=_mock_http_response(status, text)) - return patch("httpx.AsyncClient", return_value=mock_client) - - -class TestLoadOpenApiSpec: - @pytest.mark.asyncio - async def test_swagger_2_spec_normalized(self): - spec = json.dumps({"swagger": "2.0", "info": {}, "paths": {}, "host": "api.example.com"}) - with _patch_http(spec): - result = await load_openapi_spec("https://api.example.com/openapi.json") - assert result.get("openapi", "").startswith("3.") diff --git a/tests/test_schema_context.py b/tests/test_schema_context.py index 47d9947..f4a83f4 100644 --- a/tests/test_schema_context.py +++ b/tests/test_schema_context.py @@ -2,133 +2,7 @@ import pytest -from api_agent.agent.graphql_agent import ( - _build_schema_context, - _format_arg, - _format_field, - _format_type, -) - - -class TestFormatType: - """Test SDL type formatting.""" - - def test_scalar(self): - assert _format_type({"name": "String", "kind": "SCALAR"}) == "String" - - def test_non_null(self): - t = {"kind": "NON_NULL", "ofType": {"name": "String", "kind": "SCALAR"}} - assert _format_type(t) == "String!" - - def test_list(self): - t = {"kind": "LIST", "ofType": {"name": "User", "kind": "OBJECT"}} - assert _format_type(t) == "[User]" - - def test_non_null_list(self): - t = { - "kind": "NON_NULL", - "ofType": {"kind": "LIST", "ofType": {"name": "User", "kind": "OBJECT"}}, - } - assert _format_type(t) == "[User]!" - - def test_list_non_null(self): - t = { - "kind": "LIST", - "ofType": {"kind": "NON_NULL", "ofType": {"name": "User", "kind": "OBJECT"}}, - } - assert _format_type(t) == "[User!]" - - def test_deeply_nested(self): - t = { - "kind": "NON_NULL", - "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": { - "kind": "LIST", - "ofType": { - "kind": "NON_NULL", - "ofType": {"name": "User", "kind": "OBJECT"}, - }, - }, - }, - }, - } - assert _format_type(t) == "[[User!]!]!" - - def test_none(self): - assert _format_type(None) == "?" - - def test_empty(self): - assert _format_type({}) == "?" - - -class TestFormatArg: - """Test argument formatting with default values.""" - - def test_arg_no_default(self): - arg = {"name": "limit", "type": {"name": "Int", "kind": "SCALAR"}} - assert _format_arg(arg) == "limit: Int" - - def test_arg_with_default(self): - arg = {"name": "limit", "type": {"name": "Int", "kind": "SCALAR"}, "defaultValue": "10"} - assert _format_arg(arg) == "limit: Int = 10" - - def test_arg_with_string_default(self): - arg = { - "name": "order", - "type": {"name": "String", "kind": "SCALAR"}, - "defaultValue": '"ASC"', - } - assert _format_arg(arg) == 'order: String = "ASC"' - - def test_arg_with_non_null_type(self): - arg = { - "name": "id", - "type": {"kind": "NON_NULL", "ofType": {"name": "ID", "kind": "SCALAR"}}, - } - assert _format_arg(arg) == "id: ID!" - - def test_arg_with_list_type_and_default(self): - arg = { - "name": "statuses", - "type": {"kind": "LIST", "ofType": {"name": "Status", "kind": "ENUM"}}, - "defaultValue": "[ACTIVE]", - } - assert _format_arg(arg) == "statuses: [Status] = [ACTIVE]" - - -class TestFormatField: - """Test field formatting with args.""" - - def test_field_no_args(self): - fld = { - "name": "id", - "args": [], - "type": {"kind": "NON_NULL", "ofType": {"name": "ID", "kind": "SCALAR"}}, - } - assert _format_field(fld) == " id: ID!" - - def test_field_with_args(self): - fld = { - "name": "components", - "args": [ - {"name": "type", "type": {"name": "Type", "kind": "ENUM"}}, - {"name": "first", "type": {"name": "Int", "kind": "SCALAR"}}, - ], - "type": {"kind": "LIST", "ofType": {"name": "Component", "kind": "INTERFACE"}}, - } - assert _format_field(fld) == " components(type: Type, first: Int): [Component]" - - def test_field_with_description(self): - fld = { - "name": "team", - "args": [], - "type": {"name": "Team", "kind": "OBJECT"}, - "description": "Owner team", - } - assert _format_field(fld) == " team: Team # Owner team" +from api_agent.graphql.schema_context import build_schema_context class TestBuildSchemaContext: @@ -312,14 +186,14 @@ def sample_schema(self): } def test_queries_section(self, sample_schema): - ctx = _build_schema_context(sample_schema) + ctx = build_schema_context(sample_schema) assert "" in ctx # Optional args stripped - names and type are not NON_NULL at top level assert "components() -> [Component!]! # List components" in ctx assert "teams() -> [Team]" in ctx def test_interfaces_section(self, sample_schema): - ctx = _build_schema_context(sample_schema) + ctx = build_schema_context(sample_schema) assert "" in ctx assert "Component {" in ctx assert "# implemented by: Service, Job, Library" in ctx @@ -329,47 +203,47 @@ def test_interfaces_section(self, sample_schema): ) def test_unions_section(self, sample_schema): - ctx = _build_schema_context(sample_schema) + ctx = build_schema_context(sample_schema) assert "" in ctx assert "ApprovalChange: RequestToDelete | RequestToUpdate" in ctx def test_types_section_with_implements(self, sample_schema): - ctx = _build_schema_context(sample_schema) + ctx = build_schema_context(sample_schema) assert "" in ctx assert "Service implements Component {" in ctx assert "endpoint: String # API endpoint" in ctx def test_types_section_with_nested_args(self, sample_schema): - ctx = _build_schema_context(sample_schema) + ctx = build_schema_context(sample_schema) # Team.components has args assert "components(type: Type): [Component]" in ctx def test_enums_section(self, sample_schema): - ctx = _build_schema_context(sample_schema) + ctx = build_schema_context(sample_schema) assert "" in ctx assert "Type: Service | Job | Library" in ctx assert "LifecycleStatus: ACTIVE | DEPRECATED" in ctx def test_inputs_section(self, sample_schema): - ctx = _build_schema_context(sample_schema) + ctx = build_schema_context(sample_schema) assert "" in ctx # Optional fields stripped - type and teamId not NON_NULL assert "ComponentFilter { }" in ctx def test_excludes_internal_types(self, sample_schema): sample_schema["types"].append({"name": "__Schema", "kind": "OBJECT", "fields": []}) - ctx = _build_schema_context(sample_schema) + ctx = build_schema_context(sample_schema) assert "__Schema" not in ctx def test_excludes_query_mutation_subscription(self, sample_schema): sample_schema["types"].append({"name": "Query", "kind": "OBJECT", "fields": []}) sample_schema["types"].append({"name": "Mutation", "kind": "OBJECT", "fields": []}) - ctx = _build_schema_context(sample_schema) + ctx = build_schema_context(sample_schema) assert "\nQuery " not in ctx assert "\nMutation " not in ctx def test_empty_schema(self): - ctx = _build_schema_context({}) + ctx = build_schema_context({}) assert "" in ctx assert "" in ctx assert "" in ctx diff --git a/tests/test_search_schema.py b/tests/test_search_schema.py index 6907606..6bf1e61 100644 --- a/tests/test_search_schema.py +++ b/tests/test_search_schema.py @@ -2,12 +2,13 @@ import json import re +from contextvars import ContextVar import pytest -from api_agent.agent.graphql_agent import _raw_schema from api_agent.agent.schema_search import create_search_schema_impl +_raw_schema: ContextVar[str] = ContextVar("test_raw_schema") _search_schema_impl = create_search_schema_impl(_raw_schema) diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..7475703 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,31 @@ +import pytest +from fastmcp import FastMCP + +from api_agent.tools import register_all_tools + + +@pytest.mark.asyncio +async def test_execute_tool_not_registered(): + mcp = FastMCP("test") + + register_all_tools(mcp) + + names = {t.name for t in await mcp.list_tools(run_middleware=False)} + assert "_query" in names + assert "_execute" not in names + + +@pytest.mark.asyncio +async def test_query_tool_has_mcp_metadata(): + mcp = FastMCP("test") + register_all_tools(mcp) + + tool = next(t for t in await mcp.list_tools(run_middleware=False) if t.name == "_query") + + assert tool.title == "Ask API" + assert "natural-language question" in (tool.description or "") + assert "return_directly" in tool.parameters.get("properties", {}) + assert "return_directly" not in tool.parameters.get("required", []) + assert tool.annotations is not None + assert tool.annotations.readOnlyHint is None + assert tool.annotations.openWorldHint is True diff --git a/tests/test_tracing.py b/tests/test_tracing.py new file mode 100644 index 0000000..88b3b9e --- /dev/null +++ b/tests/test_tracing.py @@ -0,0 +1,24 @@ +from api_agent import tracing + + +def test_agent_span_attributes_include_openinference_kind(): + assert tracing.agent_span_attributes("api_agent", "rest") == { + "mcp_name": "api_agent", + "agent_type": "rest", + "openinference.span.kind": "AGENT", + } + + +def test_span_trace_id_formats_recording_span(): + class FakeContext: + trace_id = 0x1747C063EBC5125A0FD42E7F741F5BAB + + class FakeSpan: + def get_span_context(self): + return FakeContext() + + assert tracing.span_trace_id(FakeSpan()) == "1747c063ebc5125a0fd42e7f741f5bab" + + +def test_span_trace_id_skips_invalid_span(): + assert tracing.span_trace_id(None) is None diff --git a/uv.lock b/uv.lock index b2d7ddb..0ca0429 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,23 @@ version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version < '3.13'", ] +[[package]] +name = "aiofile" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -17,15 +30,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.0" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +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/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, + { 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]] @@ -33,16 +46,21 @@ name = "api-agent" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "arize-otel" }, { name = "duckdb" }, { name = "fastmcp" }, { name = "httpx" }, { name = "openai-agents" }, { name = "openinference-instrumentation-openai-agents" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-distro", extra = ["otlp"] }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyyaml" }, { name = "rapidfuzz" }, + { name = "redis" }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -57,90 +75,123 @@ dev = [ [package.metadata] requires-dist = [ - { name = "arize-otel", specifier = ">=0.11.0" }, - { name = "duckdb", specifier = ">=1.0.0" }, - { name = "fastmcp", specifier = ">=2.13.3" }, + { name = "duckdb", specifier = ">=1.5.3" }, + { name = "fastmcp", specifier = ">=3.3.1,<4" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "openai-agents", specifier = ">=0.0.3" }, - { name = "openinference-instrumentation-openai-agents", specifier = ">=1.4.0" }, - { name = "pydantic", specifier = ">=2.12.5" }, - { name = "pydantic-settings", specifier = ">=2.12.0" }, - { name = "pyyaml", specifier = ">=6.0" }, - { name = "rapidfuzz", specifier = ">=3.0.0" }, - { name = "starlette", specifier = ">=0.50.0" }, - { name = "uvicorn", specifier = ">=0.38.0" }, + { name = "openai-agents", specifier = ">=0.17.4" }, + { name = "openinference-instrumentation-openai-agents", specifier = ">=1.5.1" }, + { name = "opentelemetry-api", specifier = "==1.41.0" }, + { name = "opentelemetry-distro", extras = ["otlp"], specifier = "==0.62b0" }, + { name = "opentelemetry-instrumentation", specifier = "==0.62b0" }, + { name = "opentelemetry-sdk", specifier = "==1.41.0" }, + { name = "opentelemetry-semantic-conventions", specifier = "==0.62b0" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "pydantic-settings", specifier = ">=2.14.1" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "rapidfuzz", specifier = ">=3.14.5" }, + { name = "redis", specifier = ">=8.0.0" }, + { name = "starlette", specifier = ">=1.2.0" }, + { name = "uvicorn", specifier = ">=0.48.0" }, ] [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-asyncio", specifier = ">=1.3.0" }, - { name = "ruff", specifier = ">=0.8.0" }, - { name = "ty", specifier = ">=0.0.15" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "ruff", specifier = ">=0.15.15" }, + { name = "ty", specifier = ">=0.0.40" }, ] [[package]] -name = "arize-otel" -version = "0.11.0" +name = "async-timeout" +version = "5.0.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "openinference-instrumentation" }, - { name = "openinference-semantic-conventions" }, - { name = "opentelemetry-exporter-otlp" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0a/43/feecfd74cf03cd9f73bfa43155d8edb4ee38e880e7cd6ecc4277f2f4143c/arize_otel-0.11.0.tar.gz", hash = "sha256:cf0729b7c236c51c11e7054b3f96e7deadf6a813386a35391a670c64a63a09bf", size = 15801, upload-time = "2025-10-30T05:53:07.279Z" } +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/55/8b/8f5426c47eee5e4f3acaf8a8037dbe5a8d503c739e9327be8d699e6631a5/arize_otel-0.11.0-py3-none-any.whl", hash = "sha256:48e7e4c5b30d53a89c86085bd00a98077bad766ccde79ca906daa78eb17b93d2", size = 16839, upload-time = "2025-10-30T05:53:05.763Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "authlib" -version = "1.6.5" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] [[package]] name = "beartype" -version = "0.22.8" +version = "0.22.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/1d/794ae2acaa67c8b216d91d5919da2606c2bb14086849ffde7f5555f3a3a5/beartype-0.22.8.tar.gz", hash = "sha256:b19b21c9359722ee3f7cc433f063b3e13997b27ae8226551ea5062e621f61165", size = 1602262, upload-time = "2025-12-03T05:11:10.766Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2a/fbcbf5a025d3e71ddafad7efd43e34ec4362f4d523c3c471b457148fb211/beartype-0.22.8-py3-none-any.whl", hash = "sha256:b832882d04e41a4097bab9f63e6992bc6de58c414ee84cba9b45b67314f5ab2e", size = 1331895, upload-time = "2025-12-03T05:11:08.373Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] [[package]] name = "cachetools" -version = "6.2.2" +version = "7.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] [[package]] name = "certifi" -version = "2025.11.12" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +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/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, + { 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]] @@ -215,87 +266,103 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -309,69 +376,66 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +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 = "cyclopts" -version = "4.3.0" +version = "4.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -379,18 +443,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/0f/fe026df2ab8301e30a2b0bd425ff1462ad858fd4f991c1ac0389c2059c24/cyclopts-4.3.0.tar.gz", hash = "sha256:e95179cd0a959ce250ecfb2f0262a5996a92c1f9467bccad2f3d829e6833cef5", size = 151411, upload-time = "2025-11-25T02:59:33.572Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/07/bf61d13de86d96a4c46aff00c9ca0eced44bcc8c3e16280605c1253e5720/cyclopts-4.16.1.tar.gz", hash = "sha256:8aa47bf92a5fb33abca5af05e576eecdb0d2f79893ad29238046df78370fc4a8", size = 181196, upload-time = "2026-05-25T15:29:08.518Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/e8/77a231ae531cf38765b75ddf27dae28bb5f70b41d8bb4f15ce1650e93f57/cyclopts-4.3.0-py3-none-any.whl", hash = "sha256:91a30b69faf128ada7cfeaefd7d9649dc222e8b2a8697f1fc99e4ee7b7ca44f3", size = 187184, upload-time = "2025-11-25T02:59:32.21Z" }, -] - -[[package]] -name = "diskcache" -version = "5.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/7f362c2fb8ef4decd2160bc24d4292c6ca658cc6d9a161b89ca5122bbdbf/cyclopts-4.16.1-py3-none-any.whl", hash = "sha256:617795392c4113a2c2cc7af716f20244900e87f23daa05442d1268d81472a592", size = 219020, upload-time = "2026-05-25T15:29:09.646Z" }, ] [[package]] @@ -413,52 +468,47 @@ wheels = [ [[package]] name = "docstring-parser" -version = "0.17.0" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, -] - -[[package]] -name = "docutils" -version = "0.22.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/02/111134bfeb6e6c7ac4c74594e39a59f6c0195dc4846afbeac3cba60f1927/docutils-0.22.3.tar.gz", hash = "sha256:21486ae730e4ca9f622677b1412b879af1791efcfba517e4c6f60be543fc8cdd", size = 2290153, upload-time = "2025-11-06T02:35:55.655Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032, upload-time = "2025-11-06T02:35:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] [[package]] name = "duckdb" -version = "1.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/99/ac6c105118751cc3ccae980b12e44847273f3402e647ec3197aff2251e23/duckdb-1.4.2.tar.gz", hash = "sha256:df81acee3b15ecb2c72eb8f8579fb5922f6f56c71f5c8892ea3bc6fab39aa2c4", size = 18469786, upload-time = "2025-11-12T13:18:04.203Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/76/5b79eac0abcb239806da1d26f20515882a8392d0729a031af9e61d494dd4/duckdb-1.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b2d882672b61bc6117a2c524cf64ea519d2e829295951d214f04e126f1549b09", size = 29005908, upload-time = "2025-11-12T13:16:44.454Z" }, - { url = "https://files.pythonhosted.org/packages/73/1a/324d7787fdb0de96872ff7b48524830930494b45abf9501875be7456faa2/duckdb-1.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:995ec9c1fc3ce5fbfe5950b980ede2a9d51b35fdf2e3f873ce94c22fc3355fdc", size = 15398994, upload-time = "2025-11-12T13:16:46.802Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c6/a2a072ca73f91a32c0db1254dd84fec30f4d673f9d57d853802aedf867fa/duckdb-1.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19d2c2f3cdf0242cad42e803602bbc2636706fc1d2d260ffac815ea2e3a018e8", size = 13727492, upload-time = "2025-11-12T13:16:49.097Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d5/8f84b3685a8730f47e68bce46dbce789cb85c915a8c6aafdf85830589eb3/duckdb-1.4.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a496a04458590dcec8e928122ebe2ecbb42c3e1de4119f5461f7bf547acbe79", size = 18456479, upload-time = "2025-11-12T13:16:51.66Z" }, - { url = "https://files.pythonhosted.org/packages/30/7c/709a80e72a3bf013fa890fc767d2959a8a2a15abee4088559ddabcb9399f/duckdb-1.4.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2315b693f201787c9892f31eb9a0484d3c648edb3578a86dc8c1284dd2873a", size = 20458319, upload-time = "2025-11-12T13:16:54.24Z" }, - { url = "https://files.pythonhosted.org/packages/93/ff/e0b0dd10e6da48a262f3e054378a3781febf28af3381c0e1e901d0390b3c/duckdb-1.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:bdd2d808806ceeeec33ba89665a0bb707af8815f2ca40e6c4c581966c0628ba1", size = 12320864, upload-time = "2025-11-12T13:16:56.798Z" }, - { url = "https://files.pythonhosted.org/packages/c9/29/2f68c57e7c4242fedbf4b3fdc24fce2ffcf60640c936621d8a645593a161/duckdb-1.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9356fe17af2711e0a5ace4b20a0373e03163545fd7516e0c3c40428f44597052", size = 29015814, upload-time = "2025-11-12T13:16:59.329Z" }, - { url = "https://files.pythonhosted.org/packages/34/b7/030cc278a4ae788800a833b2901b9a7da7a6993121053c4155c359328531/duckdb-1.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:946a8374c0252db3fa41165ab9952b48adc8de06561a6b5fd62025ac700e492f", size = 15403892, upload-time = "2025-11-12T13:17:02.141Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/67f4798a7a29bd0813f8a1e94a83e857e57f5d1ba14cf3edc5551aad0095/duckdb-1.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:389fa9abe4ca37d091332a2f8c3ebd713f18e87dc4cb5e8efd3e5aa8ddf8885f", size = 13733622, upload-time = "2025-11-12T13:17:04.502Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ac/d0d0e3feae9663334b2336f15785d280b54a56c3ffa10334e20a51a87ecd/duckdb-1.4.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be8c0c40f2264b91500b89c688f743e1c7764966e988f680b1f19416b00052e", size = 18470220, upload-time = "2025-11-12T13:17:07.049Z" }, - { url = "https://files.pythonhosted.org/packages/a5/52/7570a50430cbffc8bd702443ac28a446b0fa4f77747a3821d4b37a852b15/duckdb-1.4.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6a21732dd52a76f1e61484c06d65800b18f57fe29e8102a7466c201a2221604", size = 20481138, upload-time = "2025-11-12T13:17:09.459Z" }, - { url = "https://files.pythonhosted.org/packages/95/5e/be05f46a290ea27630c112ff9e01fd01f585e599967fc52fe2edc7bc2039/duckdb-1.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:769440f4507c20542ae2e5b87f6c6c6d3f148c0aa8f912528f6c97e9aedf6a21", size = 12330737, upload-time = "2025-11-12T13:17:12.02Z" }, - { url = "https://files.pythonhosted.org/packages/70/c4/5054dbe79cf570b0c97db0c2eba7eb541cc561037360479059a3b57e4a32/duckdb-1.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:de646227fc2c53101ac84e86e444e7561aa077387aca8b37052f3803ee690a17", size = 29015784, upload-time = "2025-11-12T13:17:14.409Z" }, - { url = "https://files.pythonhosted.org/packages/2c/b8/97f4f07d9459f5d262751cccfb2f4256debb8fe5ca92370cebe21aab1ee2/duckdb-1.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f1fac31babda2045d4cdefe6d0fd2ebdd8d4c2a333fbcc11607cfeaec202d18d", size = 15403788, upload-time = "2025-11-12T13:17:16.864Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ea/112f33ace03682bafd4aaf0a3336da689b9834663e7032b3d678fd2902c9/duckdb-1.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:43ac632f40ab1aede9b4ce3c09ea043f26f3db97b83c07c632c84ebd7f7c0f4a", size = 13733603, upload-time = "2025-11-12T13:17:20.884Z" }, - { url = "https://files.pythonhosted.org/packages/34/83/8d6f845a9a946e8b47b6253b9edb084c45670763e815feed6cfefc957e89/duckdb-1.4.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77db030b48321bf785767b7b1800bf657dd2584f6df0a77e05201ecd22017da2", size = 18473725, upload-time = "2025-11-12T13:17:23.074Z" }, - { url = "https://files.pythonhosted.org/packages/82/29/153d1b4fc14c68e6766d7712d35a7ab6272a801c52160126ac7df681f758/duckdb-1.4.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a456adbc3459c9dcd99052fad20bd5f8ef642be5b04d09590376b2eb3eb84f5c", size = 20481971, upload-time = "2025-11-12T13:17:26.703Z" }, - { url = "https://files.pythonhosted.org/packages/58/b7/8d3a58b5ebfb9e79ed4030a0f2fbd7e404c52602e977b1e7ab51651816c7/duckdb-1.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f7c61617d2b1da3da5d7e215be616ad45aa3221c4b9e2c4d1c28ed09bc3c1c4", size = 12330535, upload-time = "2025-11-12T13:17:29.175Z" }, - { url = "https://files.pythonhosted.org/packages/25/46/0f316e4d0d6bada350b9da06691a2537c329c8948c78e8b5e0c4874bc5e2/duckdb-1.4.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:422be8c6bdc98366c97f464b204b81b892bf962abeae6b0184104b8233da4f19", size = 29028616, upload-time = "2025-11-12T13:17:31.599Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/e04a8f97865251b544aee9501088d4f0cb8e8b37339bd465c0d33857d411/duckdb-1.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:459b1855bd06a226a2838da4f14c8863fd87a62e63d414a7f7f682a7c616511a", size = 15410382, upload-time = "2025-11-12T13:17:34.14Z" }, - { url = "https://files.pythonhosted.org/packages/47/ec/b8229517c2f9fe88a38bb1a172a2da4d0ff34996d319d74554fda80b6358/duckdb-1.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20c45b4ead1ea4d23a1be1cd4f1dfc635e58b55f0dd11e38781369be6c549903", size = 13737588, upload-time = "2025-11-12T13:17:36.515Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9a/63d26da9011890a5b893e0c21845c0c0b43c634bf263af3bbca64be0db76/duckdb-1.4.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e552451054534970dc999e69ca5ae5c606458548c43fb66d772117760485096", size = 18477886, upload-time = "2025-11-12T13:17:39.136Z" }, - { url = "https://files.pythonhosted.org/packages/23/35/b1fae4c5245697837f6f63e407fa81e7ccc7948f6ef2b124cd38736f4d1d/duckdb-1.4.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:128c97dab574a438d7c8d020670b21c68792267d88e65a7773667b556541fa9b", size = 20483292, upload-time = "2025-11-12T13:17:41.501Z" }, - { url = "https://files.pythonhosted.org/packages/25/5e/6f5ebaabc12c6db62f471f86b5c9c8debd57f11aa1b2acbbcc4c68683238/duckdb-1.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:dfcc56a83420c0dec0b83e97a6b33addac1b7554b8828894f9d203955591218c", size = 12830520, upload-time = "2025-11-12T13:17:43.93Z" }, +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640, upload-time = "2026-05-20T11:55:31.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/fc/a8a89c6c73f31c2b58c6abbc2f543e0b736042dd5ef7cc1784c24ec31428/duckdb-1.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:341a2672e2551ba51c95c1898f0ade983e76675e79038ccb16342c3d6cfb82d7", size = 32583465, upload-time = "2026-05-20T11:54:13.132Z" }, + { url = "https://files.pythonhosted.org/packages/63/f1/3423a2f523dd034e505d4a5dd8e210ae577212e152598dc13b6a5e736e1b/duckdb-1.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c9e8fa408705081160ede7ead238d16e73a36b8561b700f2bf2d650ae48e7b92", size = 17278520, upload-time = "2026-05-20T11:54:16.368Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1a/7bf5ba1b7ea520557e6b2dbee1c85abab016bdac0c1779d9d0ef76c87300/duckdb-1.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70a18f932cf6d87bd0e554613657a515c1443a1724aacfc7ec5137dd28698b03", size = 15424794, upload-time = "2026-05-20T11:54:19.891Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/ce4b1e386e45fab0268edbf1b85bace20e9437589e9edb2bd5f9a226fa44/duckdb-1.5.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e80eb4d0fb59869cb2c7d7ef494c07fb92014fe8e77d96c170cd1ebc1488a708", size = 19306666, upload-time = "2026-05-20T11:54:22.77Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/651f8453f26931e8061b7e27b3090f868868185814ecb9216d0bd71ec8ef/duckdb-1.5.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3248b49cd835ea322574bc6aac0ae7a83be85547f49d4f5f5777cb380ee6627f", size = 21418306, upload-time = "2026-05-20T11:54:25.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/64/e1ffebf010b1631a6fef8d1508f46d4eab3e97c18729af986bb796fa8452/duckdb-1.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:f4eff89c12c3a362efa012262e57b7b4ab904a7f79bad9178fe365510077abe8", size = 13101423, upload-time = "2026-05-20T11:54:28.107Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/b1d4e34f9658cc0e13d7aae581ab82643f50a548d5aee8767f0c587cc3a4/duckdb-1.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:75d13308c9da3ee431d1e72b8ab720aa74a1b3e9159d4124cb62435924496334", size = 13951740, upload-time = "2026-05-20T11:54:30.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/2e34929b16c8d544ef664fad8f7f3a2a9db05746aae1e7c8c4ee3a8b23e4/duckdb-1.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ff11a457258148337ef9a392148a8cdbd1069b6c27c21958816c7b67fe6c542d", size = 32626494, upload-time = "2026-05-20T11:54:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/3a/53/3af681793d03771365ae3e2215331151c196a3ac8193f613344840694671/duckdb-1.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fd25f533cb1b6b2c84cc767a9a9bab7769bb1aa44571a2a0bfc91ac3e4a38ac", size = 17301121, upload-time = "2026-05-20T11:54:36.928Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/c80af1eac2ab5d35fc2c372ef0a84668842e549fbbf7799277b3fccf3e39/duckdb-1.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10960400ed60cdf0fe05bab2086fa8eb733889cb0ceca18d07ff9a00c0e0be7b", size = 15449283, upload-time = "2026-05-20T11:54:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/c63af233c9f761bf5178a5210437e1bc6bcb30fa8a9073de6398cfb12c03/duckdb-1.5.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5f18e7561403054433706c187589e86629a7af09a7efc23a06a8b308e6acc68", size = 19332762, upload-time = "2026-05-20T11:54:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/2d77af4fff86012f334ef82e6d54a995a86c8745e58074f1218ed7d25171/duckdb-1.5.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fb7516255a8764545e30f7efacea408cc847764a3027b3b0b3e7d1a7bebbc5c", size = 21453290, upload-time = "2026-05-20T11:54:45.272Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/9bc4817a98feb4dab83e56f2245cd3a30d00ee646d4dec7926464e2b3f28/duckdb-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:8001eccbc28be244dfd04d708526f34ddd6460b47a8aeb5d0e39d6f7f9e3fe15", size = 13118308, upload-time = "2026-05-20T11:54:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/e3f32e4e53e2450ddb1db8312a17d1ce455d60cc4941b6ad2cfc908794b0/duckdb-1.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:6d2835e39bb6af73891f73c0f8d4324f98afe00d0b00c6d34b2a582c2256cbb0", size = 13927187, upload-time = "2026-05-20T11:54:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" }, + { url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, + { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, + { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, + { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, ] [[package]] @@ -488,103 +538,134 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.13.3" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/a9/5c5a01b6abd5346bf60b97cfd29e4a86661940c27dd562bfcda07fd03519/fastmcp-3.3.1.tar.gz", hash = "sha256:979362ea557de42a5f40342563c7e4b236bcc8e7cd192715f50030695d1a71cd", size = 28681699, upload-time = "2026-05-15T15:50:39.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/11/6b1bdada6ccfe647d615ae63f9106f8136aec17971e9361546af01c7d38e/fastmcp-3.3.1-py3-none-any.whl", hash = "sha256:862440c5c4d281363a5995eee59d77f0f7cac1f18869038729cecf03b02fc522", size = 7903, upload-time = "2026-05-15T15:50:36.424Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "3.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/a0/627103e517e1d0d6f1eec633d5662d13e776f01b45ad188e4f5f7478b438/fastmcp_slim-3.3.1.tar.gz", hash = "sha256:0957835fc59452e143ab2f4b7836d2d2df9b2d9958408edc79ba8b56232b2a88", size = 567007, upload-time = "2026-05-15T15:50:10.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ee/97047f4cc2d7b1d46670d08d8ad01a96e7a748cc01c0b4b351ad8eddbc7a/fastmcp_slim-3.3.1-py3-none-any.whl", hash = "sha256:6cf1c2d77e3adb0d409d6825ed6b0b2a999062973e00b8eea03bd48bf9b4c043", size = 738644, upload-time = "2026-05-15T15:50:08.336Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, +] +server = [ { name = "authlib" }, { name = "cyclopts" }, { name = "exceptiongroup" }, + { name = "griffelib" }, { name = "httpx" }, + { name = "jsonref" }, { name = "jsonschema-path" }, { name = "mcp" }, { name = "openapi-pydantic" }, - { name = "platformdirs" }, - { name = "py-key-value-aio", extra = ["disk", "memory"] }, - { name = "pydantic", extra = ["email"] }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "pyperclip" }, - { name = "python-dotenv" }, - { name = "rich" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "uncalled-for" }, { name = "uvicorn" }, + { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/a1/a507bfb73f51983759cbbc3702b6f4780128cff68ebbc51db2f10170c950/fastmcp-2.13.3.tar.gz", hash = "sha256:ebca59e99412c596dd75ebdd5147800f6abc2490d025af76fa8ea4fc5f68781d", size = 8185958, upload-time = "2025-12-03T23:58:00.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/bc/56925f1202357dbfcfdfd0c75afc6c27ec1e6ef1d89b7e7410df3945ceb4/fastmcp-2.13.3-py3-none-any.whl", hash = "sha256:5173d335f4e6aabcfb5a5131af3fa092f604b303130fd3a49226b7a844a48e65", size = 385644, upload-time = "2025-12-03T23:58:02.246Z" }, -] [[package]] name = "googleapis-common-protos" -version = "1.72.0" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] [[package]] -name = "griffe" -version = "1.15.0" +name = "griffelib" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] [[package]] name = "grpcio" -version = "1.76.0" +version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, - { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, - { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, - { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, - { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, - { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, - { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, + { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, + { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] [[package]] @@ -635,23 +716,23 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, ] [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] [[package]] @@ -663,94 +744,165 @@ 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 = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jiter" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435, upload-time = "2025-11-09T20:47:02.087Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/40c9f7c22f5e6ff715f28113ebaba27ab85f9af2660ad6e1dd6425d14c19/jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd", size = 320548, upload-time = "2025-11-09T20:47:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/6b/1b/efbb68fe87e7711b00d2cfd1f26bb4bfc25a10539aefeaa7727329ffb9cb/jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423", size = 351915, upload-time = "2025-11-09T20:47:05.171Z" }, - { url = "https://files.pythonhosted.org/packages/15/2d/c06e659888c128ad1e838123d0638f0efad90cc30860cb5f74dd3f2fc0b3/jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7", size = 368966, upload-time = "2025-11-09T20:47:06.508Z" }, - { url = "https://files.pythonhosted.org/packages/6b/20/058db4ae5fb07cf6a4ab2e9b9294416f606d8e467fb74c2184b2a1eeacba/jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2", size = 482047, upload-time = "2025-11-09T20:47:08.382Z" }, - { url = "https://files.pythonhosted.org/packages/49/bb/dc2b1c122275e1de2eb12905015d61e8316b2f888bdaac34221c301495d6/jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9", size = 380835, upload-time = "2025-11-09T20:47:09.81Z" }, - { url = "https://files.pythonhosted.org/packages/23/7d/38f9cd337575349de16da575ee57ddb2d5a64d425c9367f5ef9e4612e32e/jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6", size = 364587, upload-time = "2025-11-09T20:47:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a3/b13e8e61e70f0bb06085099c4e2462647f53cc2ca97614f7fedcaa2bb9f3/jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725", size = 390492, upload-time = "2025-11-09T20:47:12.993Z" }, - { url = "https://files.pythonhosted.org/packages/07/71/e0d11422ed027e21422f7bc1883c61deba2d9752b720538430c1deadfbca/jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6", size = 522046, upload-time = "2025-11-09T20:47:14.6Z" }, - { url = "https://files.pythonhosted.org/packages/9f/59/b968a9aa7102a8375dbbdfbd2aeebe563c7e5dddf0f47c9ef1588a97e224/jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e", size = 513392, upload-time = "2025-11-09T20:47:16.011Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e4/7df62002499080dbd61b505c5cb351aa09e9959d176cac2aa8da6f93b13b/jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c", size = 206096, upload-time = "2025-11-09T20:47:17.344Z" }, - { url = "https://files.pythonhosted.org/packages/bb/60/1032b30ae0572196b0de0e87dce3b6c26a1eff71aad5fe43dee3082d32e0/jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f", size = 204899, upload-time = "2025-11-09T20:47:19.365Z" }, - { url = "https://files.pythonhosted.org/packages/49/d5/c145e526fccdb834063fb45c071df78b0cc426bbaf6de38b0781f45d956f/jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5", size = 188070, upload-time = "2025-11-09T20:47:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449, upload-time = "2025-11-09T20:47:22.999Z" }, - { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855, upload-time = "2025-11-09T20:47:24.779Z" }, - { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171, upload-time = "2025-11-09T20:47:26.469Z" }, - { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590, upload-time = "2025-11-09T20:47:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462, upload-time = "2025-11-09T20:47:29.654Z" }, - { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983, upload-time = "2025-11-09T20:47:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328, upload-time = "2025-11-09T20:47:33.286Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740, upload-time = "2025-11-09T20:47:34.703Z" }, - { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875, upload-time = "2025-11-09T20:47:36.058Z" }, - { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457, upload-time = "2025-11-09T20:47:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546, upload-time = "2025-11-09T20:47:40.47Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196, upload-time = "2025-11-09T20:47:41.794Z" }, - { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100, upload-time = "2025-11-09T20:47:43.007Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658, upload-time = "2025-11-09T20:47:44.424Z" }, - { url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605, upload-time = "2025-11-09T20:47:45.973Z" }, - { url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803, upload-time = "2025-11-09T20:47:47.535Z" }, - { url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120, upload-time = "2025-11-09T20:47:49.284Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918, upload-time = "2025-11-09T20:47:50.807Z" }, - { url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008, upload-time = "2025-11-09T20:47:52.211Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785, upload-time = "2025-11-09T20:47:53.512Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108, upload-time = "2025-11-09T20:47:54.893Z" }, - { url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937, upload-time = "2025-11-09T20:47:56.253Z" }, - { url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853, upload-time = "2025-11-09T20:47:58.32Z" }, - { url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699, upload-time = "2025-11-09T20:47:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258, upload-time = "2025-11-09T20:48:01.01Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503, upload-time = "2025-11-09T20:48:02.35Z" }, - { url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965, upload-time = "2025-11-09T20:48:03.783Z" }, - { url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831, upload-time = "2025-11-09T20:48:05.55Z" }, - { url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272, upload-time = "2025-11-09T20:48:06.951Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604, upload-time = "2025-11-09T20:48:08.328Z" }, - { url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628, upload-time = "2025-11-09T20:48:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" }, - { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" }, - { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" }, - { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" }, - { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" }, - { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" }, - { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" }, - { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" }, - { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" }, - { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" }, - { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/5339ef1ecaa881c6948669956567a64d2670941925f245c434f494ffb0e5/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:4739a4657179ebf08f85914ce50332495811004cc1747852e8b2041ed2aab9b8", size = 311144, upload-time = "2025-11-09T20:49:10.503Z" }, - { url = "https://files.pythonhosted.org/packages/27/74/3446c652bffbd5e81ab354e388b1b5fc1d20daac34ee0ed11ff096b1b01a/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:41da8def934bf7bec16cb24bd33c0ca62126d2d45d81d17b864bd5ad721393c3", size = 305877, upload-time = "2025-11-09T20:49:12.269Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f4/ed76ef9043450f57aac2d4fbeb27175aa0eb9c38f833be6ef6379b3b9a86/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c44ee814f499c082e69872d426b624987dbc5943ab06e9bbaa4f81989fdb79e", size = 340419, upload-time = "2025-11-09T20:49:13.803Z" }, - { url = "https://files.pythonhosted.org/packages/21/01/857d4608f5edb0664aa791a3d45702e1a5bcfff9934da74035e7b9803846/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd2097de91cf03eaa27b3cbdb969addf83f0179c6afc41bbc4513705e013c65d", size = 347212, upload-time = "2025-11-09T20:49:15.643Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f5/12efb8ada5f5c9edc1d4555fe383c1fb2eac05ac5859258a72d61981d999/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb", size = 309974, upload-time = "2025-11-09T20:49:17.187Z" }, - { url = "https://files.pythonhosted.org/packages/85/15/d6eb3b770f6a0d332675141ab3962fd4a7c270ede3515d9f3583e1d28276/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b", size = 304233, upload-time = "2025-11-09T20:49:18.734Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3e/e7e06743294eea2cf02ced6aa0ff2ad237367394e37a0e2b4a1108c67a36/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f", size = 338537, upload-time = "2025-11-09T20:49:20.317Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + +[[package]] +name = "joserfc" +version = "1.6.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, ] [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -758,24 +910,24 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] name = "jsonschema-path" -version = "0.3.4" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "attrs" }, { name = "pathable" }, { name = "pyyaml" }, { name = "referencing" }, - { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, ] [[package]] @@ -790,21 +942,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +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/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { 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 = "mcp" -version = "1.22.0" +version = "1.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -822,9 +992,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/a2/c5ec0ab38b35ade2ae49a90fada718fbc76811dc5aa1760414c6aaa6b08a/mcp-1.22.0.tar.gz", hash = "sha256:769b9ac90ed42134375b19e777a2858ca300f95f2e800982b3e2be62dfc0ba01", size = 471788, upload-time = "2025-11-20T20:11:28.095Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/bb/711099f9c6bb52770f56e56401cdfb10da5b67029f701e0df29362df4c8e/mcp-1.22.0-py3-none-any.whl", hash = "sha256:bed758e24df1ed6846989c909ba4e3df339a27b4f30f1b8b627862a4bade4e98", size = 175489, upload-time = "2025-11-20T20:11:26.542Z" }, + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, ] [[package]] @@ -836,9 +1006,18 @@ 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 = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "openai" -version = "2.9.0" +version = "2.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -850,27 +1029,28 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/48/516290f38745cc1e72856f50e8afed4a7f9ac396a5a18f39e892ab89dfc2/openai-2.9.0.tar.gz", hash = "sha256:b52ec65727fc8f1eed2fbc86c8eac0998900c7ef63aa2eb5c24b69717c56fa5f", size = 608202, upload-time = "2025-12-04T18:15:09.01Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/12/cfa322c5f5dd8fa21aab9a7a8e979e7a11123800f86ca8d82eb68a83d213/openai-2.38.0.tar.gz", hash = "sha256:798694c6cf74145541fda94325b6f8f72d8e1fd0262cc137c8d728177a6a4ce3", size = 772764, upload-time = "2026-05-21T21:23:42.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/fd/ae2da789cd923dd033c99b8d544071a827c92046b150db01cfa5cea5b3fd/openai-2.9.0-py3-none-any.whl", hash = "sha256:0d168a490fbb45630ad508a6f3022013c155a68fd708069b6a1a01a5e8f0ffad", size = 1030836, upload-time = "2025-12-04T18:15:07.063Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/ccff9be562e24207716d04ef9dc931c76aff0c89a7265da43e2104d7fe06/openai-2.38.0-py3-none-any.whl", hash = "sha256:ec6661c57b2dcc47414a767e6e3335c7ed3d19c9696999283a3c82e95c756a3c", size = 1344910, upload-time = "2026-05-21T21:23:39.636Z" }, ] [[package]] name = "openai-agents" -version = "0.6.2" +version = "0.17.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "griffe" }, + { name = "griffelib" }, { name = "mcp" }, { name = "openai" }, { name = "pydantic" }, { name = "requests" }, { name = "types-requests" }, { name = "typing-extensions" }, + { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/6d/824b78c3161e0204f6fc71b459af64d8c65325a5486bc41b8eedcca7a715/openai_agents-0.6.2.tar.gz", hash = "sha256:1012aee224518292778fb4b07eb9148b0b5efa5cd87fd32ec656296aba885612", size = 2014662, upload-time = "2025-12-04T22:37:28.397Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/48/ba0ad614c66d88100d61b9ddaf0793022a425b5b958c0139c2f1c7473764/openai_agents-0.17.4.tar.gz", hash = "sha256:6af9afd4b40de23493c9ab285c28cd4e8fd088240af6e96e2dee45826ad568fd", size = 5409840, upload-time = "2026-05-26T08:55:10.459Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/0a/43e24985d9df314d3dfa5f004443e8a15ef2bdcc79718dc74ded5545bf7d/openai_agents-0.6.2-py3-none-any.whl", hash = "sha256:3156637f3eee925268943f5c4b500b3b22b179158b82e7b53ed7ab362edf84d6", size = 238302, upload-time = "2025-12-04T22:37:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/89/adf09ec269d4de1c7be37c747d8488aac51b58d5589a9c1dd55f3c1e8e05/openai_agents-0.17.4-py3-none-any.whl", hash = "sha256:feea8264c9812bba7c526a01f6efd4f8c0efdb348c2233c36ff9c292a9d465af", size = 842963, upload-time = "2026-05-26T08:55:08.767Z" }, ] [[package]] @@ -887,7 +1067,7 @@ wheels = [ [[package]] name = "openinference-instrumentation" -version = "0.1.42" +version = "0.1.52" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "openinference-semantic-conventions" }, @@ -895,14 +1075,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/d0/b19061a21fd6127d2857c77744a36073bba9c1502d1d5e8517b708eb8b7c/openinference_instrumentation-0.1.42.tar.gz", hash = "sha256:2275babc34022e151b5492cfba41d3b12e28377f8e08cb45e5d64fe2d9d7fe37", size = 23954, upload-time = "2025-11-05T01:37:46.869Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/0d/10f658e2a7738a55c7de9717d07cfbb40537f2fc620c94718bd9e1b21c4a/openinference_instrumentation-0.1.52.tar.gz", hash = "sha256:0fce0f390f706ca37f385ee64f19df8b290bf9e43e9a701c97377d2ad2745807", size = 33307, upload-time = "2026-05-22T21:10:45.943Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/71/43ee4616fc95dbd2f560550f199c6652a5eb93f84e8aa0039bc95c19cfe0/openinference_instrumentation-0.1.42-py3-none-any.whl", hash = "sha256:e7521ff90833ef7cc65db526a2f59b76a496180abeaaee30ec6abbbc0b43f8ec", size = 30086, upload-time = "2025-11-05T01:37:43.866Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/c730507f42a538e76a12fc315b1e65e08c9d0344f6eb448189a086724120/openinference_instrumentation-0.1.52-py3-none-any.whl", hash = "sha256:98f668a5ceaf057eeee82272e855160131849766b5ffe3266cb1c229d9dfe561", size = 40508, upload-time = "2026-05-22T21:10:44.152Z" }, ] [[package]] name = "openinference-instrumentation-openai-agents" -version = "1.4.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "openinference-instrumentation" }, @@ -913,61 +1093,80 @@ dependencies = [ { name = "typing-extensions" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/40/ac0a3ad5040d2582156f6c0fa2b8f6233af79af295dab154d642d42aed69/openinference_instrumentation_openai_agents-1.4.0.tar.gz", hash = "sha256:2fd50d03f6d999b9793566a1f2787bf9e2cd3774fa8bf32542250dfc61e32d62", size = 12746, upload-time = "2025-12-04T19:58:36.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/65/a39fa0a276216db1f3ca46cb5b31c910e18590855e9dcb1a49867d2cf262/openinference_instrumentation_openai_agents-1.5.1.tar.gz", hash = "sha256:3282832eeeb95be0606efaf99c9180f072736888b84680eab0163209a30c420b", size = 12832, upload-time = "2026-05-18T18:51:33.511Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/e5/299103b68f5427a7d11acd0f4804c5b3f3e9508a511f8f8078a43ad7e6bd/openinference_instrumentation_openai_agents-1.4.0-py3-none-any.whl", hash = "sha256:539361d0f3bdebdb1e898250fbba8e6173f2bce9d7ba007cf7934f10850f474b", size = 14411, upload-time = "2025-12-04T19:58:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/d8f76ec95cfdb3e042015b71ea3b68cf3c9aade5c0fcf3e5e1485753fdfc/openinference_instrumentation_openai_agents-1.5.1-py3-none-any.whl", hash = "sha256:f12359ee16d91fe04704e552c94df5b042456b9a8734b5ea082ab30b8c3d2216", size = 14538, upload-time = "2026-05-18T18:51:32.094Z" }, ] [[package]] name = "openinference-semantic-conventions" -version = "0.1.25" +version = "0.1.30" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/68/81c8a0b90334ff11e4f285e4934c57f30bea3ef0c0b9f99b65e7b80fae3b/openinference_semantic_conventions-0.1.25.tar.gz", hash = "sha256:f0a8c2cfbd00195d1f362b4803518341e80867d446c2959bf1743f1894fce31d", size = 12767, upload-time = "2025-11-05T01:37:45.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/51/8ba1182ee86fc79793d5ff2d11e7fdcda10ded2d01f3e46ca6fcf0568213/openinference_semantic_conventions-0.1.30.tar.gz", hash = "sha256:81fece76e09c83789e35c393b8b30523481eeabf1008745b955631a53e3221d9", size = 13391, upload-time = "2026-05-22T21:10:44.065Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/3d/dd14ee2eb8a3f3054249562e76b253a1545c76adbbfd43a294f71acde5c3/openinference_semantic_conventions-0.1.25-py3-none-any.whl", hash = "sha256:3814240f3bd61f05d9562b761de70ee793d55b03bca1634edf57d7a2735af238", size = 10395, upload-time = "2025-11-05T01:37:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/5b7e78cf0de38589b821bbe8e9c29c59a6e76edfb980488d0854cbb90f7c/openinference_semantic_conventions-0.1.30-py3-none-any.whl", hash = "sha256:36d946d3f95f699b7c4b12324ae9c1f02d6c7750df11eece56aa159cff430b3d", size = 10911, upload-time = "2026-05-22T21:10:43.04Z" }, ] [[package]] name = "opentelemetry-api" -version = "1.39.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/0b/e5428c009d4d9af0515b0a8371a8aaae695371af291f45e702f7969dce6b/opentelemetry_api-1.39.0.tar.gz", hash = "sha256:6130644268c5ac6bdffaf660ce878f10906b3e789f7e2daa5e169b047a2933b9", size = 65763, upload-time = "2025-12-03T13:19:56.378Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/85/d831a9bc0a9e0e1a304ff3d12c1489a5fbc9bf6690a15dcbdae372bbca45/opentelemetry_api-1.39.0-py3-none-any.whl", hash = "sha256:3c3b3ca5c5687b1b5b37e5c5027ff68eacea8675241b29f13110a8ffbb8f0459", size = 66357, upload-time = "2025-12-03T13:19:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, +] + +[[package]] +name = "opentelemetry-distro" +version = "0.62b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/c6/52b0dbcc8fbdecf179047921940516cbb8aaf05f6b737faa526ad76fec51/opentelemetry_distro-0.62b0.tar.gz", hash = "sha256:aa0308fbe50ad8f17d4446982dbf26870e20b8031ba38d8e1224ecf7aedd3184", size = 2611, upload-time = "2026-04-09T14:40:20.404Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/7e/5858bba1c7ed880c7b0fe7d9a1ea40ab8affd18c9ebc1e16c2d69c501da1/opentelemetry_distro-0.62b0-py3-none-any.whl", hash = "sha256:23e9065a35cef12868ad5efb18ce9c88a9103800256b318dec4c9c850c6c78c1", size = 3348, upload-time = "2026-04-09T14:39:17.406Z" }, +] + +[package.optional-dependencies] +otlp = [ + { name = "opentelemetry-exporter-otlp" }, ] [[package]] name = "opentelemetry-exporter-otlp" -version = "1.39.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/be/0e9d889f47e55cadc4041e5b53d4e0cc688f9a74811134fb0ba7cbee6905/opentelemetry_exporter_otlp-1.39.0.tar.gz", hash = "sha256:b405da0287b895fe4e2450dedb2a5b072debba1dfcfed5bdb3d1d183d8daa296", size = 6146, upload-time = "2025-12-03T13:19:58.381Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/b7/845565a2ab5d22c1486bc7729a06b05cd0964c61539d766e1f107c9eea0c/opentelemetry_exporter_otlp-1.41.0.tar.gz", hash = "sha256:97ff847321f8d4c919032a67d20d3137fb7b34eac0c47f13f71112858927fc5b", size = 6152, upload-time = "2026-04-09T14:38:35.895Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/35/212d2cae4fa9a2c02e74438612268b640ab577b8ccb04590371eb4e0f542/opentelemetry_exporter_otlp-1.39.0-py3-none-any.whl", hash = "sha256:fe155d6968d581b325574ad6dc267c8de299397b18d11feeda2206d0a47928a9", size = 7017, upload-time = "2025-12-03T13:19:35.686Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f2/f1076fff152858773f22cda146713f9ae3661795af6bacd411a76f2151ac/opentelemetry_exporter_otlp-1.41.0-py3-none-any.whl", hash = "sha256:443b6a45c990ae4c55e147f97049a86c5f5b704f3d78b48b44a073a886ec4d6e", size = 7022, upload-time = "2026-04-09T14:38:13.934Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.39.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/cb/3a29ce606b10c76d413d6edd42d25a654af03e73e50696611e757d2602f3/opentelemetry_exporter_otlp_proto_common-1.39.0.tar.gz", hash = "sha256:a135fceed1a6d767f75be65bd2845da344dd8b9258eeed6bc48509d02b184409", size = 20407, upload-time = "2025-12-03T13:19:59.003Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/c6/215edba62d13a3948c718b289539f70e40965bc37fc82ecd55bb0b749c1a/opentelemetry_exporter_otlp_proto_common-1.39.0-py3-none-any.whl", hash = "sha256:3d77be7c4bdf90f1a76666c934368b8abed730b5c6f0547a2ec57feb115849ac", size = 18367, upload-time = "2025-12-03T13:19:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.39.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -978,14 +1177,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/62/4db083ee9620da3065eeb559e9fc128f41a1d15e7c48d7c83aafbccd354c/opentelemetry_exporter_otlp_proto_grpc-1.39.0.tar.gz", hash = "sha256:7e7bb3f436006836c0e0a42ac619097746ad5553ad7128a5bd4d3e727f37fc06", size = 24650, upload-time = "2025-12-03T13:20:00.06Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/46/d75a3f8c91915f2e58f61d0a2e4ada63891e7c7a37a20ff7949ba184a6b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0.tar.gz", hash = "sha256:f704201251c6f65772b11bddea1c948000554459101bdbb0116e0a01b70592f6", size = 25754, upload-time = "2026-04-09T14:38:37.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/e8/d420b94ffddfd8cff85bb4aa5d98da26ce7935dc3cf3eca6b83cd39ab436/opentelemetry_exporter_otlp_proto_grpc-1.39.0-py3-none-any.whl", hash = "sha256:758641278050de9bb895738f35ff8840e4a47685b7e6ef4a201fe83196ba7a05", size = 19765, upload-time = "2025-12-03T13:19:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/81/f6/b09e2e0c9f0b5750cebc6eaf31527b910821453cef40a5a0fe93550422b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0-py3-none-any.whl", hash = "sha256:3a1a86bd24806ccf136ec9737dbfa4c09b069f9130ff66b0acb014f9c5255fd1", size = 20299, upload-time = "2026-04-09T14:38:17.01Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.39.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -996,14 +1195,14 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/dc/1e9bf3f6a28e29eba516bc0266e052996d02bc7e92675f3cd38169607609/opentelemetry_exporter_otlp_proto_http-1.39.0.tar.gz", hash = "sha256:28d78fc0eb82d5a71ae552263d5012fa3ebad18dfd189bf8d8095ba0e65ee1ed", size = 17287, upload-time = "2025-12-03T13:20:01.134Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/46/e4a102e17205bb05a50dbf24ef0e92b66b648cd67db9a68865af06a242fd/opentelemetry_exporter_otlp_proto_http-1.39.0-py3-none-any.whl", hash = "sha256:5789cb1375a8b82653328c0ce13a054d285f774099faf9d068032a49de4c7862", size = 19639, upload-time = "2025-12-03T13:19:39.536Z" }, + { url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.60b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1011,84 +1210,75 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/3c/bd53dbb42eff93d18e3047c7be11224aa9966ce98ac4cc5bfb860a32c95a/opentelemetry_instrumentation-0.60b0.tar.gz", hash = "sha256:4e9fec930f283a2677a2217754b40aaf9ef76edae40499c165bc7f1d15366a74", size = 31707, upload-time = "2025-12-03T13:22:00.352Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/fd/b8e90bb340957f059084376f94cff336b0e871a42feba7d3f7342365e987/opentelemetry_instrumentation-0.62b0.tar.gz", hash = "sha256:aa1b0b9ab2e1722c2a8a5384fb016fc28d30bba51826676c8036074790d2861e", size = 34042, upload-time = "2026-04-09T14:40:22.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/7b/5b5b9f8cfe727a28553acf9cd287b1d7f706f5c0a00d6e482df55b169483/opentelemetry_instrumentation-0.60b0-py3-none-any.whl", hash = "sha256:aaafa1483543a402819f1bdfb06af721c87d60dd109501f9997332862a35c76a", size = 33096, upload-time = "2025-12-03T13:20:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/3356d2e335e3c449c5183e9b023f30f04f1b7073a6583c68745ea2e704b1/opentelemetry_instrumentation-0.62b0-py3-none-any.whl", hash = "sha256:30d4e76486eae64fb095264a70c2c809c4bed17b73373e53091470661f7d477c", size = 34158, upload-time = "2026-04-09T14:39:21.428Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.39.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/b5/64d2f8c3393cd13ea2092106118f7b98461ba09333d40179a31444c6f176/opentelemetry_proto-1.39.0.tar.gz", hash = "sha256:c1fa48678ad1a1624258698e59be73f990b7fc1f39e73e16a9d08eef65dd838c", size = 46153, upload-time = "2025-12-03T13:20:08.729Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/4d/d500e1862beed68318705732d1976c390f4a72ca8009c4983ff627acff20/opentelemetry_proto-1.39.0-py3-none-any.whl", hash = "sha256:1e086552ac79acb501485ff0ce75533f70f3382d43d0a30728eeee594f7bf818", size = 72534, upload-time = "2025-12-03T13:19:50.251Z" }, + { url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.39.0" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/e3/7cd989003e7cde72e0becfe830abff0df55c69d237ee7961a541e0167833/opentelemetry_sdk-1.39.0.tar.gz", hash = "sha256:c22204f12a0529e07aa4d985f1bca9d6b0e7b29fe7f03e923548ae52e0e15dde", size = 171322, upload-time = "2025-12-03T13:20:09.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/0e/a586df1186f9f56b5a0879d52653effc40357b8e88fc50fe300038c3c08b/opentelemetry_sdk-1.41.0.tar.gz", hash = "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd", size = 230181, upload-time = "2026-04-09T14:38:47.225Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/b4/2adc8bc83eb1055ecb592708efb6f0c520cc2eb68970b02b0f6ecda149cf/opentelemetry_sdk-1.39.0-py3-none-any.whl", hash = "sha256:90cfb07600dfc0d2de26120cebc0c8f27e69bf77cd80ef96645232372709a514", size = 132413, upload-time = "2025-12-03T13:19:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/2c/13/a7825118208cb32e6a4edcd0a99f925cbef81e77b3b0aedfd9125583c543/opentelemetry_sdk-1.41.0-py3-none-any.whl", hash = "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd", size = 180214, upload-time = "2026-04-09T14:38:30.657Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.60b0" +version = "0.62b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/0e/176a7844fe4e3cb5de604212094dffaed4e18b32f1c56b5258bcbcba85c2/opentelemetry_semantic_conventions-0.60b0.tar.gz", hash = "sha256:227d7aa73cbb8a2e418029d6b6465553aa01cf7e78ec9d0bc3255c7b3ac5bf8f", size = 137935, upload-time = "2025-12-03T13:20:12.395Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/b0/c14f723e86c049b7bf8ff431160d982519b97a7be2857ed2247377397a24/opentelemetry_semantic_conventions-0.62b0.tar.gz", hash = "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097", size = 145753, upload-time = "2026-04-09T14:38:48.274Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/56/af0306666f91bae47db14d620775604688361f0f76a872e0005277311131/opentelemetry_semantic_conventions-0.60b0-py3-none-any.whl", hash = "sha256:069530852691136018087b52688857d97bba61cd641d0f8628d2d92788c4f78a", size = 219981, upload-time = "2025-12-03T13:19:53.585Z" }, + { url = "https://files.pythonhosted.org/packages/58/6c/5e86fa1759a525ef91c2d8b79d668574760ff3f900d114297765eb8786cb/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", size = 231619, upload-time = "2026-04-09T14:38:32.394Z" }, ] [[package]] name = "packaging" -version = "25.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +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/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { 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 = "pathable" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, -] - -[[package]] -name = "pathvalidate" -version = "3.3.1" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, ] [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -1102,66 +1292,56 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.2" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/44/e49ecff446afeec9d1a66d6bbf9adc21e3c7cea7803a920ca3773379d4f6/protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4", size = 444296, upload-time = "2025-12-06T00:17:53.311Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/91/1e3a34881a88697a7354ffd177e8746e97a722e5e8db101544b47e84afb1/protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d", size = 425603, upload-time = "2025-12-06T00:17:41.114Z" }, - { url = "https://files.pythonhosted.org/packages/64/20/4d50191997e917ae13ad0a235c8b42d8c1ab9c3e6fd455ca16d416944355/protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4", size = 436930, upload-time = "2025-12-06T00:17:43.278Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ca/7e485da88ba45c920fb3f50ae78de29ab925d9e54ef0de678306abfbb497/protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43", size = 427621, upload-time = "2025-12-06T00:17:44.445Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4f/f743761e41d3b2b2566748eb76bbff2b43e14d5fcab694f494a16458b05f/protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e", size = 324460, upload-time = "2025-12-06T00:17:45.678Z" }, - { url = "https://files.pythonhosted.org/packages/b1/fa/26468d00a92824020f6f2090d827078c09c9c587e34cbfd2d0c7911221f8/protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872", size = 339168, upload-time = "2025-12-06T00:17:46.813Z" }, - { url = "https://files.pythonhosted.org/packages/56/13/333b8f421738f149d4fe5e49553bc2a2ab75235486259f689b4b91f96cec/protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f", size = 323270, upload-time = "2025-12-06T00:17:48.253Z" }, - { url = "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", size = 170501, upload-time = "2025-12-06T00:17:52.211Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] name = "py-key-value-aio" -version = "0.3.0" +version = "0.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beartype" }, - { name = "py-key-value-shared" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, ] [package.optional-dependencies] -disk = [ - { name = "diskcache" }, - { name = "pathvalidate" }, +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, ] memory = [ { name = "cachetools" }, ] -[[package]] -name = "py-key-value-shared" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, -] - [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +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/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { 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.12.5" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1169,9 +1349,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +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/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { 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.optional-dependencies] @@ -1181,131 +1361,136 @@ email = [ [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +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/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 = "pydantic-settings" -version = "2.12.0" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +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/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { 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 = "pyjwt" -version = "2.10.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -1324,7 +1509,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1333,40 +1518,40 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +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/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { 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" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { 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" } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/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" }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.29" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, ] [[package]] @@ -1388,6 +1573,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1445,100 +1639,112 @@ wheels = [ [[package]] name = "rapidfuzz" -version = "3.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/25/5b0a33ad3332ee1213068c66f7c14e9e221be90bab434f0cb4defa9d6660/rapidfuzz-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dea2d113e260a5da0c4003e0a5e9fdf24a9dc2bb9eaa43abd030a1e46ce7837d", size = 1953885, upload-time = "2025-11-01T11:52:47.75Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ab/f1181f500c32c8fcf7c966f5920c7e56b9b1d03193386d19c956505c312d/rapidfuzz-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e6c31a4aa68cfa75d7eede8b0ed24b9e458447db604c2db53f358be9843d81d3", size = 1390200, upload-time = "2025-11-01T11:52:49.491Z" }, - { url = "https://files.pythonhosted.org/packages/14/2a/0f2de974ececad873865c6bb3ea3ad07c976ac293d5025b2d73325aac1d4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02821366d928e68ddcb567fed8723dad7ea3a979fada6283e6914d5858674850", size = 1389319, upload-time = "2025-11-01T11:52:51.224Z" }, - { url = "https://files.pythonhosted.org/packages/ed/69/309d8f3a0bb3031fd9b667174cc4af56000645298af7c2931be5c3d14bb4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe8df315ab4e6db4e1be72c5170f8e66021acde22cd2f9d04d2058a9fd8162e", size = 3178495, upload-time = "2025-11-01T11:52:53.005Z" }, - { url = "https://files.pythonhosted.org/packages/10/b7/f9c44a99269ea5bf6fd6a40b84e858414b6e241288b9f2b74af470d222b1/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:769f31c60cd79420188fcdb3c823227fc4a6deb35cafec9d14045c7f6743acae", size = 1228443, upload-time = "2025-11-01T11:52:54.991Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0a/3b3137abac7f19c9220e14cd7ce993e35071a7655e7ef697785a3edfea1a/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:54fa03062124e73086dae66a3451c553c1e20a39c077fd704dc7154092c34c63", size = 2411998, upload-time = "2025-11-01T11:52:56.629Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b6/983805a844d44670eaae63831024cdc97ada4e9c62abc6b20703e81e7f9b/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:834d1e818005ed0d4ae38f6b87b86fad9b0a74085467ece0727d20e15077c094", size = 2530120, upload-time = "2025-11-01T11:52:58.298Z" }, - { url = "https://files.pythonhosted.org/packages/b4/cc/2c97beb2b1be2d7595d805682472f1b1b844111027d5ad89b65e16bdbaaa/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:948b00e8476a91f510dd1ec07272efc7d78c275d83b630455559671d4e33b678", size = 4283129, upload-time = "2025-11-01T11:53:00.188Z" }, - { url = "https://files.pythonhosted.org/packages/4d/03/2f0e5e94941045aefe7eafab72320e61285c07b752df9884ce88d6b8b835/rapidfuzz-3.14.3-cp311-cp311-win32.whl", hash = "sha256:43d0305c36f504232f18ea04e55f2059bb89f169d3119c4ea96a0e15b59e2a91", size = 1724224, upload-time = "2025-11-01T11:53:02.149Z" }, - { url = "https://files.pythonhosted.org/packages/cf/99/5fa23e204435803875daefda73fd61baeabc3c36b8fc0e34c1705aab8c7b/rapidfuzz-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:ef6bf930b947bd0735c550683939a032090f1d688dfd8861d6b45307b96fd5c5", size = 1544259, upload-time = "2025-11-01T11:53:03.66Z" }, - { url = "https://files.pythonhosted.org/packages/48/35/d657b85fcc615a42661b98ac90ce8e95bd32af474603a105643963749886/rapidfuzz-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:f3eb0ff3b75d6fdccd40b55e7414bb859a1cda77c52762c9c82b85569f5088e7", size = 814734, upload-time = "2025-11-01T11:53:05.008Z" }, - { url = "https://files.pythonhosted.org/packages/fa/8e/3c215e860b458cfbedb3ed73bc72e98eb7e0ed72f6b48099604a7a3260c2/rapidfuzz-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:685c93ea961d135893b5984a5a9851637d23767feabe414ec974f43babbd8226", size = 1945306, upload-time = "2025-11-01T11:53:06.452Z" }, - { url = "https://files.pythonhosted.org/packages/36/d9/31b33512015c899f4a6e6af64df8dfe8acddf4c8b40a4b3e0e6e1bcd00e5/rapidfuzz-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa7c8f26f009f8c673fbfb443792f0cf8cf50c4e18121ff1e285b5e08a94fbdb", size = 1390788, upload-time = "2025-11-01T11:53:08.721Z" }, - { url = "https://files.pythonhosted.org/packages/a9/67/2ee6f8de6e2081ccd560a571d9c9063184fe467f484a17fa90311a7f4a2e/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57f878330c8d361b2ce76cebb8e3e1dc827293b6abf404e67d53260d27b5d941", size = 1374580, upload-time = "2025-11-01T11:53:10.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382", size = 3154947, upload-time = "2025-11-01T11:53:12.093Z" }, - { url = "https://files.pythonhosted.org/packages/5b/cf/9f49831085a16384695f9fb096b99662f589e30b89b4a589a1ebc1a19d34/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:07aa0b5d8863e3151e05026a28e0d924accf0a7a3b605da978f0359bb804df43", size = 1223872, upload-time = "2025-11-01T11:53:13.664Z" }, - { url = "https://files.pythonhosted.org/packages/c8/0f/41ee8034e744b871c2e071ef0d360686f5ccfe5659f4fd96c3ec406b3c8b/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73b07566bc7e010e7b5bd490fb04bb312e820970180df6b5655e9e6224c137db", size = 2392512, upload-time = "2025-11-01T11:53:15.109Z" }, - { url = "https://files.pythonhosted.org/packages/da/86/280038b6b0c2ccec54fb957c732ad6b41cc1fd03b288d76545b9cf98343f/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6de00eb84c71476af7d3110cf25d8fe7c792d7f5fa86764ef0b4ca97e78ca3ed", size = 2521398, upload-time = "2025-11-01T11:53:17.146Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7b/05c26f939607dca0006505e3216248ae2de631e39ef94dd63dbbf0860021/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7843a1abf0091773a530636fdd2a49a41bcae22f9910b86b4f903e76ddc82dc", size = 4259416, upload-time = "2025-11-01T11:53:19.34Z" }, - { url = "https://files.pythonhosted.org/packages/40/eb/9e3af4103d91788f81111af1b54a28de347cdbed8eaa6c91d5e98a889aab/rapidfuzz-3.14.3-cp312-cp312-win32.whl", hash = "sha256:dea97ac3ca18cd3ba8f3d04b5c1fe4aa60e58e8d9b7793d3bd595fdb04128d7a", size = 1709527, upload-time = "2025-11-01T11:53:20.949Z" }, - { url = "https://files.pythonhosted.org/packages/b8/63/d06ecce90e2cf1747e29aeab9f823d21e5877a4c51b79720b2d3be7848f8/rapidfuzz-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:b5100fd6bcee4d27f28f4e0a1c6b5127bc8ba7c2a9959cad9eab0bf4a7ab3329", size = 1538989, upload-time = "2025-11-01T11:53:22.428Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6d/beee32dcda64af8128aab3ace2ccb33d797ed58c434c6419eea015fec779/rapidfuzz-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:4e49c9e992bc5fc873bd0fff7ef16a4405130ec42f2ce3d2b735ba5d3d4eb70f", size = 811161, upload-time = "2025-11-01T11:53:23.811Z" }, - { url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" }, - { url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" }, - { url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" }, - { url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" }, - { url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" }, - { url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" }, - { url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" }, - { url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" }, - { url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" }, - { url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" }, - { url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" }, - { url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" }, - { url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" }, - { url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" }, - { url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" }, - { url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" }, - { url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" }, - { url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" }, - { url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" }, - { url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" }, - { url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" }, - { url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" }, - { url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" }, - { url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" }, - { url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" }, - { url = "https://files.pythonhosted.org/packages/c9/33/b5bd6475c7c27164b5becc9b0e3eb978f1e3640fea590dd3dced6006ee83/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7cf174b52cb3ef5d49e45d0a1133b7e7d0ecf770ed01f97ae9962c5c91d97d23", size = 1888499, upload-time = "2025-11-01T11:54:42.094Z" }, - { url = "https://files.pythonhosted.org/packages/30/d2/89d65d4db4bb931beade9121bc71ad916b5fa9396e807d11b33731494e8e/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:442cba39957a008dfc5bdef21a9c3f4379e30ffb4e41b8555dbaf4887eca9300", size = 1336747, upload-time = "2025-11-01T11:54:43.957Z" }, - { url = "https://files.pythonhosted.org/packages/85/33/cd87d92b23f0b06e8914a61cea6850c6d495ca027f669fab7a379041827a/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1faa0f8f76ba75fd7b142c984947c280ef6558b5067af2ae9b8729b0a0f99ede", size = 1352187, upload-time = "2025-11-01T11:54:45.518Z" }, - { url = "https://files.pythonhosted.org/packages/22/20/9d30b4a1ab26aac22fff17d21dec7e9089ccddfe25151d0a8bb57001dc3d/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e6eefec45625c634926a9fd46c9e4f31118ac8f3156fff9494422cee45207e6", size = 3101472, upload-time = "2025-11-01T11:54:47.255Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ad/fa2d3e5c29a04ead7eaa731c7cd1f30f9ec3c77b3a578fdf90280797cbcb/rapidfuzz-3.14.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56fefb4382bb12250f164250240b9dd7772e41c5c8ae976fd598a32292449cc5", size = 1511361, upload-time = "2025-11-01T11:54:49.057Z" }, +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" }, + { url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d0/4539e42a2d596e068f7738f279638a4a74edd1fbb6f8594e2458058979c6/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925", size = 3168906, upload-time = "2026-04-07T11:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1c/3ec897eb9d8b05308aa8ef6ae4ed64b088ad521a3f9d8ff469e7e97bc2b0/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8", size = 1478176, upload-time = "2026-04-07T11:14:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ba/970c03a12ce20a5399e22afe9f8932fd4cd1265b8a8461d0e63b00eb4eae/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13", size = 2402441, upload-time = "2026-04-07T11:14:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/81/93/61d351cae60c1d0e21ba5ff1a1015ad045539ed215da9d6e302204ed887a/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489", size = 2511628, upload-time = "2026-04-07T11:14:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/87/52/374d2d4f60fd98155142a869323aa221e30868cfa1f15171a0f64070c247/rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6", size = 4275480, upload-time = "2026-04-07T11:14:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/d8/04/82e7989bc9ec20a15b720a335c5cb6b0724bf6582013898f90a3280cfccd/rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f", size = 1725627, upload-time = "2026-04-07T11:14:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b5/eca8ac5609bc9bcb02bb6ff87fa5983cc92b8772d66a431556ab8a8c178f/rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819", size = 1545977, upload-time = "2026-04-07T11:14:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e1/dbf318de28f65fa2cdd0a9dfbdee380f8199eb83b19259bc4f8592551b4e/rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb", size = 816827, upload-time = "2026-04-07T11:14:16.788Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, + { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, + { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, + { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, + { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ee/e71853bf82846c5c2174b924b71d8e8099fb05ff87c958a720380b434ba3/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c", size = 1888603, upload-time = "2026-04-07T11:16:18.223Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/40f67b730f32be2ebad9f62add1571c754f52249254b2e88af094b907eee/rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d", size = 1120599, upload-time = "2026-04-07T11:16:20.682Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/a3635cc4ec8fc6e14b46e7db1f7f8763d8c4bef33dcc124eea2e6cb2c8f3/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53", size = 1348524, upload-time = "2026-04-07T11:16:23.451Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1b/2b229520f0b48464cfcd7aa758f74551d12c9bc4ab544022a60210aab064/rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5", size = 3099302, upload-time = "2026-04-07T11:16:25.858Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b5/363906b1064fc6fe611783a61764927bbd91919aaaabe8cba82151ca93ef/rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf", size = 1509889, upload-time = "2026-04-07T11:16:28.487Z" }, +] + +[[package]] +name = "redis" +version = "8.0.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/53/ae/ed461cca5780b5fc8b9fe8ca0ed98d89508645fb9d880c24cc42c087678f/redis-8.0.0.tar.gz", hash = "sha256:a00c5355432051ac14e593b8b197fc76c887ee12d55a0984f69328a1115fdc49", size = 5101591, upload-time = "2026-05-28T12:45:13.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e3/b519734372d305bd547534a9f32e4ce9f98552af753dce72cf3483a0ff0b/redis-8.0.0-py3-none-any.whl", hash = "sha256:c938c18338585009f0bc310f4c7e4e4b4d37639356c4ac072cedf3af570c8dc7", size = 499870, upload-time = "2026-05-28T12:45:11.697Z" }, ] [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1546,169 +1752,210 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "rich" -version = "14.2.0" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +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/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, + { 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 = "rich-rst" -version = "1.3.2" +version = "2.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils" }, + { name = "pygments" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/56/3191bae66b08ccc637ea8120426068bcb361cc323c96404c310886937067/rich_rst-2.0.1.tar.gz", hash = "sha256:cbe236ed0901d1ec8427cc6a50bf0a34353ba28ad014dc24def68bfe7f3b9e68", size = 300570, upload-time = "2026-05-16T00:47:57.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3d/55c17d3ebdf3cd81356002afe5bef9bb8af631db2819785b6eac845b925b/rich_rst-2.0.1-py3-none-any.whl", hash = "sha256:7ee15f345ce25fa02b582c272a6cdbaf0c21243e38061cea273cff659bf3ef61", size = 272922, upload-time = "2026-05-16T00:47:55.508Z" }, ] [[package]] name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, ] [[package]] name = "ruff" -version = "0.14.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" }, - { url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" }, - { url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" }, - { url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" }, - { url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" }, - { url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" }, - { url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" }, - { url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" }, - { url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" }, - { url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" }, - { url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" }, - { url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" }, - { url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" }, - { url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890, upload-time = "2025-12-04T15:06:11.668Z" }, - { url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826, upload-time = "2025-12-04T15:06:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" }, +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] [[package]] @@ -1722,75 +1969,77 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.0.3" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, ] [[package]] name = "starlette" -version = "0.50.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/616a066c2760f6c2b1ae3437cc28149734d069fbb46511712beae118a68c/starlette-1.2.0.tar.gz", hash = "sha256:3c5a6b23fff42492914e93890bb80cbfea72dbf37de268eec06185d62a4ca553", size = 2668923, upload-time = "2026-05-28T11:42:50.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, ] [[package]] name = "tqdm" -version = "4.67.1" +version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] name = "ty" -version = "0.0.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/25/257602d316b9333089b688a7a11b33ebc660b74e8dacf400dc3dfdea1594/ty-0.0.15.tar.gz", hash = "sha256:4f9a5b8df208c62dba56e91b93bed8b5bb714839691b8cff16d12c983bfa1174", size = 5101936, upload-time = "2026-02-05T01:06:34.922Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/c5/35626e732b79bf0e6213de9f79aff59b5f247c0a1e3ce0d93e675ab9b728/ty-0.0.15-py3-none-linux_armv6l.whl", hash = "sha256:68e092458516c61512dac541cde0a5e4e5842df00b4e81881ead8f745ddec794", size = 10138374, upload-time = "2026-02-05T01:07:03.804Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8a/48fd81664604848f79d03879b3ca3633762d457a069b07e09fb1b87edd6e/ty-0.0.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79f2e75289eae3cece94c51118b730211af4ba5762906f52a878041b67e54959", size = 9947858, upload-time = "2026-02-05T01:06:47.453Z" }, - { url = "https://files.pythonhosted.org/packages/b6/85/c1ac8e97bcd930946f4c94db85b675561d590b4e72703bf3733419fc3973/ty-0.0.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:112a7b26e63e48cc72c8c5b03227d1db280cfa57a45f2df0e264c3a016aa8c3c", size = 9443220, upload-time = "2026-02-05T01:06:44.98Z" }, - { url = "https://files.pythonhosted.org/packages/3c/d9/244bc02599d950f7a4298fbc0c1b25cc808646b9577bdf7a83470b2d1cec/ty-0.0.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71f62a2644972975a657d9dc867bf901235cde51e8d24c20311067e7afd44a56", size = 9949976, upload-time = "2026-02-05T01:07:01.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/ab/3a0daad66798c91a33867a3ececf17d314ac65d4ae2bbbd28cbfde94da63/ty-0.0.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e48b42be2d257317c85b78559233273b655dd636fc61e7e1d69abd90fd3cba4", size = 9965918, upload-time = "2026-02-05T01:06:54.283Z" }, - { url = "https://files.pythonhosted.org/packages/39/4e/e62b01338f653059a7c0cd09d1a326e9a9eedc351a0f0de9db0601658c3d/ty-0.0.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27dd5b52a421e6871c5bfe9841160331b60866ed2040250cb161886478ab3e4f", size = 10424943, upload-time = "2026-02-05T01:07:08.777Z" }, - { url = "https://files.pythonhosted.org/packages/65/b5/7aa06655ce69c0d4f3e845d2d85e79c12994b6d84c71699cfb437e0bc8cf/ty-0.0.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76b85c9ec2219e11c358a7db8e21b7e5c6674a1fb9b6f633836949de98d12286", size = 10964692, upload-time = "2026-02-05T01:06:37.103Z" }, - { url = "https://files.pythonhosted.org/packages/13/04/36fdfe1f3c908b471e246e37ce3d011175584c26d3853e6c5d9a0364564c/ty-0.0.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e8204c61d8ede4f21f2975dce74efdb80fafb2fae1915c666cceb33ea3c90b", size = 10692225, upload-time = "2026-02-05T01:06:49.714Z" }, - { url = "https://files.pythonhosted.org/packages/13/41/5bf882649bd8b64ded5fbce7fb8d77fb3b868de1a3b1a6c4796402b47308/ty-0.0.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af87c3be7c944bb4d6609d6c63e4594944b0028c7bd490a525a82b88fe010d6d", size = 10516776, upload-time = "2026-02-05T01:06:52.047Z" }, - { url = "https://files.pythonhosted.org/packages/56/75/66852d7e004f859839c17ffe1d16513c1e7cc04bcc810edb80ca022a9124/ty-0.0.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:50dccf7398505e5966847d366c9e4c650b8c225411c2a68c32040a63b9521eea", size = 9928828, upload-time = "2026-02-05T01:06:56.647Z" }, - { url = "https://files.pythonhosted.org/packages/65/72/96bc16c7b337a3ef358fd227b3c8ef0c77405f3bfbbfb59ee5915f0d9d71/ty-0.0.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bd797b8f231a4f4715110259ad1ad5340a87b802307f3e06d92bfb37b858a8f3", size = 9978960, upload-time = "2026-02-05T01:06:29.567Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/d2e316a35b626de2227f832cd36d21205e4f5d96fd036a8af84c72ecec1b/ty-0.0.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9deb7f20e18b25440a9aa4884f934ba5628ef456dbde91819d5af1a73da48af3", size = 10135903, upload-time = "2026-02-05T01:06:59.256Z" }, - { url = "https://files.pythonhosted.org/packages/02/d3/b617a79c9dad10c888d7c15cd78859e0160b8772273637b9c4241a049491/ty-0.0.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7b31b3de031255b90a5f4d9cb3d050feae246067c87130e5a6861a8061c71754", size = 10615879, upload-time = "2026-02-05T01:07:06.661Z" }, - { url = "https://files.pythonhosted.org/packages/fb/b0/2652a73c71c77296a6343217063f05745da60c67b7e8a8e25f2064167fce/ty-0.0.15-py3-none-win32.whl", hash = "sha256:9362c528ceb62c89d65c216336d28d500bc9f4c10418413f63ebc16886e16cc1", size = 9578058, upload-time = "2026-02-05T01:06:42.928Z" }, - { url = "https://files.pythonhosted.org/packages/84/6e/08a4aedebd2a6ce2784b5bc3760e43d1861f1a184734a78215c2d397c1df/ty-0.0.15-py3-none-win_amd64.whl", hash = "sha256:4db040695ae67c5524f59cb8179a8fa277112e69042d7dfdac862caa7e3b0d9c", size = 10457112, upload-time = "2026-02-05T01:06:39.885Z" }, - { url = "https://files.pythonhosted.org/packages/b3/be/1991f2bc12847ae2d4f1e3ac5dcff8bb7bc1261390645c0755bb55616355/ty-0.0.15-py3-none-win_arm64.whl", hash = "sha256:e5a98d4119e77d6136461e16ae505f8f8069002874ab073de03fbcb1a5e8bf25", size = 9937490, upload-time = "2026-02-05T01:06:32.388Z" }, +version = "0.0.40" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/f8/a754c96967b71de8723f88be17df8738216bd382ffed229cd500b7a24d13/ty-0.0.40.tar.gz", hash = "sha256:883b53dd98f6e5b33ab1c8e1a3cd94b0f29c762ef22cdf1e86aaffb4fd711c67", size = 5726484, upload-time = "2026-05-27T17:55:43.615Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/42/d029a72165ad39f95228b67355927fbd35c821dc8e3e475d49f47c2eeb1e/ty-0.0.40-py3-none-linux_armv6l.whl", hash = "sha256:9defb4742450e569a6a09de286a04008d6c2e815112da4362c88b6eaa2f52a36", size = 11406372, upload-time = "2026-05-27T17:55:49.633Z" }, + { url = "https://files.pythonhosted.org/packages/23/99/7f8ea09b7e49afbf795cb3341a3217f30f228db7e62a2268ed8cbbf813d6/ty-0.0.40-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:868258a3330db88b683fcafe2c4e936d6226a6312799bf15b585d93557b2d38c", size = 11159782, upload-time = "2026-05-27T17:55:47.405Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/1ea745ee97a98b26ae9564d19a430a76a35297cd450e84dcaad22e1f7ee8/ty-0.0.40-py3-none-macosx_11_0_arm64.whl", hash = "sha256:589c81060cf1e7a9ffa2f45bfa35ffd9b9fbd214104e3f13959f113627efcd91", size = 10594139, upload-time = "2026-05-27T17:55:37.206Z" }, + { url = "https://files.pythonhosted.org/packages/39/1a/fbef21273c6617ff4715b4827ee1c0b6550aa7d1df4b8c43b325545c1cf4/ty-0.0.40-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b06108990cb338d941c315ae6e9ba2fff8f518bc15d3f33e5619ff6a6c9beab", size = 11114156, upload-time = "2026-05-27T17:55:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/389fc4976d7ec016a7473cf1274bf9c4f491bb54c66649bd022bff9f2b6a/ty-0.0.40-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3913ef37336bec4f96bd2512f8c3a543ca34c259b7170f7eb5adf75b3ed7f04c", size = 11189050, upload-time = "2026-05-27T17:55:54.099Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a9/4ecabbf4bdda7df0d99d8d3892c6edac0efc8c4cae756a5109178a3d0e86/ty-0.0.40-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fd1486bd5fe48779a8aa857137f3642a0a9161f5cf57d4380f4a0ecea01c8f3", size = 11664266, upload-time = "2026-05-27T17:55:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/02/0aa78730116507c265afb1d6d5961c583b49d4c2e368c4a49fd81bcae6dc/ty-0.0.40-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1668364d5254a734329917ee66c2c5fdd5665389d41043f6fce0f22ddb32b749", size = 12187743, upload-time = "2026-05-27T17:56:04.337Z" }, + { url = "https://files.pythonhosted.org/packages/e6/68/ccabf2d173523598271a385c1d3f864dbda23e5ebdc67f5969b9e830ea05/ty-0.0.40-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f77a73edb91e5dfa2ab9af7c4cac64614f8cc121f38a8875f22e830d3aba6a", size = 11862999, upload-time = "2026-05-27T17:55:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/6d7ec22771bb23d534797cdb446eb644bccfe7a62b729bb99e7235a02fc3/ty-0.0.40-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1274ce0212ecbfed01bda7c3659c46e8bd0068e32d00c46c790466a95274c3df", size = 11743896, upload-time = "2026-05-27T17:56:00.017Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a4/f9fa076b010c91cb249b1fcc3476569b7b8462cb4b688da2d04c23a0622f/ty-0.0.40-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5ee1261dbc363e5cc1a0c5bb0c8612c192bfe53491214df8bc85a540835685f9", size = 11883581, upload-time = "2026-05-27T17:56:02.319Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0f/5b776a2328c756d574dd4d6afbd30fc24e1ab4b76935c7c3c23f27ebbcb9/ty-0.0.40-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6220e2cd5cdc4683dd87fb150d195bbd9f1a021395e04cb08bd3c66ea6da6ef8", size = 11093946, upload-time = "2026-05-27T17:55:33.284Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/eb23154bae83ad7c2935e9e5916660fb3e31598a92ee232aebd79410480c/ty-0.0.40-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:46b9ed69d01d98ef046afac9983c68336f572605ea2a27b90fbe6f80bfc8d6b7", size = 11210737, upload-time = "2026-05-27T17:55:45.523Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/1fb2529703f708cacfd13a89f98613cae2907dfa941b26976467e6119803/ty-0.0.40-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ddbca9fab4406260f141674ab5efcfe7b02bd468e6985e4cdde0a21626e69ffe", size = 11332563, upload-time = "2026-05-27T17:55:41.674Z" }, + { url = "https://files.pythonhosted.org/packages/87/69/b3f5a8ef26c31204e0391147b3adcdb0674eda3e7d99868478ef168a41c6/ty-0.0.40-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1fcc082a749e6dc11b68fe9aab0420238bbf2a2374c2c7aa3c22e8c1618b136", size = 11843216, upload-time = "2026-05-27T17:55:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e8/20193069d32787f3e1a6ec8940aaa3759d3de8f48f9281bcc0c5cb0939da/ty-0.0.40-py3-none-win32.whl", hash = "sha256:75feb115b3587824c5bdf8f8305e9547b0d1e398e3077b0addc7a1988ea9bb50", size = 10670731, upload-time = "2026-05-27T17:55:31.316Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f9/8b2aa4da61db81322d4a2f9db227afeb48110ca15ae31d380f64c64ceb63/ty-0.0.40-py3-none-win_amd64.whl", hash = "sha256:b0f905edaad788bd61f779a85801b60a267a25ed57fca05aaddd168d9d8896be", size = 11766211, upload-time = "2026-05-27T17:55:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/04/87/369056ed46f1b235130ec0595393262f9cd2061ca3dab276d490980f9343/ty-0.0.40-py3-none-win_arm64.whl", hash = "sha256:07da2b09d9130e2c9a257d2a29beb53105835b0256ee5fdb288fe1aab83fee47", size = 11117369, upload-time = "2026-05-27T17:55:39.329Z" }, ] [[package]] name = "types-requests" -version = "2.32.4.20250913" +version = "2.33.0.20260518" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, ] [[package]] @@ -1814,134 +2063,280 @@ 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 = "uncalled-for" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, +] + [[package]] name = "urllib3" -version = "2.5.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "uvicorn" -version = "0.38.0" +version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] [[package]] name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] name = "wrapt" -version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, - { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, ] [[package]] name = "zipp" -version = "3.23.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ]