diff --git a/docs/spec/vla-manager-api.yaml b/docs/spec/vla-manager-api.yaml new file mode 100644 index 0000000..2e87efc --- /dev/null +++ b/docs/spec/vla-manager-api.yaml @@ -0,0 +1,567 @@ +--- +openapi: 3.1.0 +info: + title: VLA Manager API + version: 0.2.0 + description: |- + The **VLA Manager API** is the Python/FastAPI service hosted at the + **Data Intermediary**. It is the *sole owner* of both + **Veracity Level Agreements (VLAs)** and **VLA Templates** — no other + service in the Data Veracity Assurance building block is permitted to + read or write these resources directly. + + Endpoints are grouped under two tags: + + * `VLA` — create, list, fetch and bulk-delete VLAs. + * `Templates` — create, list, fetch, patch, render, delete and + bulk-delete VLA Templates. Template fields use **camelCase** on the + wire (e.g. `criterionType`, `targetAspect`, `evaluationMethod`). + * `Dev` — bulk deletion operations intended for development/test + environments only; they require a Bearer API key. +servers: + - url: http://localhost:9099 + description: Data Intermediary +tags: + - name: VLA + description: Endpoints related to veracity level agreements (VLAs). + - name: Templates + description: Endpoints related to the management of VLA templates. + - name: Dev + description: Development-only bulk deletion endpoints (require Bearer API key). +paths: + /template: + get: + tags: [Templates] + summary: List all templates + description: Returns all VLA Templates known to the Data Intermediary. + operationId: listTemplates + responses: + '200': + description: The list of available VLA Templates. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Template' + post: + tags: [Templates] + summary: Create a template + description: Create a new VLA Template. An `id` is generated automatically. + operationId: createTemplate + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateCreate' + examples: + TestTemplate: + summary: Test template + value: + name: TestTemplate + description: VLA Template used for testing + criterionType: VALID_INVALID + targetAspect: SYNTAX + evaluationMethod: + engine: JQ + variableSchema: + properties: + date: + type: string + implementationTemplate: '{ success: .date == "{{ date }}", details: "date matches" }' + responses: + '201': + description: Template created. + content: + application/json: + schema: + $ref: '#/components/schemas/Id' + '400': + description: Invalid request body. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: [Dev] + summary: Delete all templates + description: Bulk-deletes every VLA Template. Intended for + development/test environments only. Requires a Bearer API key. + operationId: deleteTemplates + security: + - ApiKey: [] + responses: + '204': + description: All templates deleted. + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /template/{id}: + parameters: + - $ref: '#/components/parameters/idParam' + get: + tags: [Templates] + summary: Get a template by ID + description: Returns the VLA Template identified by the given UUID. + operationId: getTemplate + responses: + '200': + description: The requested template. + content: + application/json: + schema: + $ref: '#/components/schemas/Template' + '404': + description: No template with the given ID exists. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + patch: + tags: [Templates] + summary: Partially update a template + description: |- + Applies a partial update to the VLA Template identified by the + path `id`. The request body **must** contain an `id` field that + matches the path parameter, otherwise the service responds with + `400 Bad Request`. + operationId: updateTemplate + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplatePatch' + responses: + '200': + description: Template updated; returns the updated template. + content: + application/json: + schema: + $ref: '#/components/schemas/Template' + '400': + description: Path `id` does not match body `id`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: No template with the given ID exists. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: [Templates] + summary: Delete a template + description: Deletes the VLA Template identified by the given UUID. + operationId: deleteTemplate + responses: + '204': + description: Template deleted. + '404': + description: No template with the given ID exists. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /template/{id}/render: + parameters: + - $ref: '#/components/parameters/idParam' + post: + tags: [Templates] + summary: Render a template + description: |- + Renders the template's `implementationTemplate` (a Handlebars + template, e.g. `{{ date }}`) with the supplied model. Returns + the engine and the rendered implementation string. + operationId: renderTemplate + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + description: The template model — any JSON object whose keys + match the variables declared in the template's + `variableSchema`. + examples: + DateModel: + summary: Date model + value: + date: '20250101T000000Z' + responses: + '200': + description: Template rendered successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/RenderResult' + example: + engine: JQ + implementation: '{ success: .date == "20250101T000000Z", details: "date matches" }' + '400': + description: The template could not be rendered. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: No template with the given ID exists. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /vla: + get: + tags: [VLA] + summary: List all VLAs + description: Returns all VLAs known to the Data Intermediary as an array. + operationId: listVLAs + responses: + '200': + description: The list of known VLAs. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/VLA' + post: + tags: [VLA] + summary: Create a VLA + description: Create a new Veracity Level Agreement. An ID is generated + automatically and injected into the stored document. + operationId: createVLA + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VLACreate' + examples: + XapiLearningTrace: + summary: E2E VLA — xAPI learning trace + value: + description: E2E VLA — xAPI learning trace + schema: + name: xapi_statement + logicalType: object + properties: + - name: actor + logicalType: object + required: true + - name: verb + logicalType: object + required: true + quality: + - engine: JQ + implementation: '{ success: (.actor.name | length > 0), details: "actor name non-empty" }' + responses: + '201': + description: VLA created. + content: + application/json: + schema: + $ref: '#/components/schemas/Id' + '400': + description: Invalid request body. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: [Dev] + summary: Delete all VLAs + description: Bulk-deletes every VLA. Intended for development/test + environments only. Requires a Bearer API key. + operationId: deleteVLAs + security: + - ApiKey: [] + responses: + '204': + description: All VLAs deleted. + '401': + description: Missing or invalid API key. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /vla/{id}: + parameters: + - $ref: '#/components/parameters/idParam' + get: + tags: [VLA] + summary: Get a VLA by ID + description: Returns the VLA identified by the given UUID. + operationId: getVLA + responses: + '200': + description: The requested VLA. + content: + application/json: + schema: + $ref: '#/components/schemas/VLA' + '404': + description: No VLA with the given ID exists. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /vla/from-templates: + post: + tags: [VLA] + summary: Create a VLA from templates + description: |- + Creates a VLA by rendering a set of VLA templates with supplied + models. Each entry in `qualityTemplates` is rendered and the + resulting quality requirements are merged into the VLA's + `quality` array before persistence. + operationId: createVLAFromTemplates + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VLANewFromTemplates' + responses: + '201': + description: VLA created. Returns the new VLA id. + content: + application/json: + schema: + $ref: '#/components/schemas/IDDTO' + '400': + description: Failed to render one of the templates. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: One of the referenced template ids was not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + parameters: + idParam: + name: id + in: path + required: true + description: UUID identifying the resource. + schema: + type: string + format: uuid + securitySchemes: + ApiKey: + type: apiKey + in: header + name: Authorization + description: Bearer API key used to authorise bulk-delete operations. + schemas: + Id: + type: object + required: [id] + additionalProperties: false + properties: + id: + type: string + format: uuid + description: UUID of the newly created resource. + VLA: + type: object + additionalProperties: true + description: |- + A Veracity Level Agreement document following the Open Data + Contract Standard (ODCS). Stored as an arbitrary JSON object; + the service injects an `id` (string, UUID) field on creation. + VLACreate: + type: object + additionalProperties: true + description: |- + Body of `POST /vla`. All fields are optional; any subset of an + ODCS data contract may be supplied. An `id` is generated by the + service and must not be set by the client. + properties: + description: + type: string + servers: + type: array + schema: + type: object + description: An ODCS schema block (object or list of properties). + quality: + type: array + items: + $ref: '#/components/schemas/DataQuality' + price: + type: object + team: + type: array + roles: + type: array + slaProperties: + type: array + support: + type: array + tags: + type: array + VLANewFromTemplates: + type: object + description: |- + Body of `POST /vla/from-templates`. Extends VLACreate with a + `qualityTemplates` array. Each entry is rendered and the + result is merged into the VLA's `quality` array. + allOf: + - $ref: '#/components/schemas/VLACreate' + required: [qualityTemplates] + properties: + qualityTemplates: + type: array + items: + type: object + required: [id, model] + properties: + id: + type: string + format: uuid + description: UUID of the VLA template to render. + model: + type: object + description: Key-value pairs to substitute into the template. + additionalProperties: true + example: + id: 3c58c2fd-6d7a-4953-9f76-7c71fc3ac7e2 + model: + value: ok + DataQuality: + type: object + required: [engine, implementation] + additionalProperties: false + properties: + engine: + $ref: '#/components/schemas/QualityEngine' + implementation: + type: string + description: The veracity-check implementation (e.g. a jq expression). + Template: + type: object + required: [id, name, criterionType, targetAspect, evaluationMethod] + additionalProperties: false + properties: + id: + type: string + format: uuid + readOnly: true + name: + type: string + description: + type: string + criterionType: + $ref: '#/components/schemas/CriterionType' + targetAspect: + $ref: '#/components/schemas/QualityAspect' + evaluationMethod: + $ref: '#/components/schemas/EvaluationMethod' + EvaluationMethod: + type: object + required: [engine, variableSchema, implementationTemplate] + additionalProperties: false + properties: + engine: + $ref: '#/components/schemas/QualityEngine' + variableSchema: + type: object + description: JSON schema describing the variables the template + expects when rendered. + implementationTemplate: + type: string + description: |- + A Handlebars template (e.g. `{{ date }}`) that is rendered + with the model supplied to `POST /template/{id}/render`. + TemplateCreate: + type: object + required: [name, criterionType, targetAspect, evaluationMethod] + additionalProperties: false + description: Body of `POST /template`. An `id` is generated by the + service and must not be supplied by the client. + properties: + name: + type: string + description: + type: string + criterionType: + $ref: '#/components/schemas/CriterionType' + targetAspect: + $ref: '#/components/schemas/QualityAspect' + evaluationMethod: + $ref: '#/components/schemas/EvaluationMethod' + example: + name: TestTemplate + description: VLA Template used for testing + criterionType: VALID_INVALID + targetAspect: SYNTAX + evaluationMethod: + engine: JQ + variableSchema: + properties: + date: + type: string + implementationTemplate: '{ success: .date == "{{ date }}", details: "date matches" }' + TemplatePatch: + type: object + required: [id] + additionalProperties: false + description: |- + Body of `PATCH /template/{id}`. The `id` field is **required** + and must match the `{id}` path parameter; otherwise the service + responds with `400 Bad Request`. All other fields are optional — + only the supplied fields are updated. + properties: + id: + type: string + format: uuid + name: + type: string + description: + type: string + criterionType: + $ref: '#/components/schemas/CriterionType' + targetAspect: + $ref: '#/components/schemas/QualityAspect' + evaluationMethod: + $ref: '#/components/schemas/EvaluationMethod' + RenderResult: + type: object + required: [engine, implementation] + additionalProperties: false + properties: + engine: + $ref: '#/components/schemas/QualityEngine' + implementation: + type: string + description: The rendered data-quality fragment. + QualityEngine: + type: string + enum: [SCHEMA, GREAT_EXPECTATIONS, JQ] + CriterionType: + type: string + enum: [VALID_INVALID, IN_RANGE, GREATER_THAN, LESS_THAN] + QualityAspect: + type: string + enum: [SYNTAX, TIMELINESS, ACCURACY, COMPLETENESS, CONSISTENCY] + Error: + type: object + required: [type, title] + additionalProperties: false + properties: + type: + type: string + title: + type: string \ No newline at end of file diff --git a/vla-manager-api/Dockerfile b/vla-manager-api/Dockerfile new file mode 100644 index 0000000..4b243cb --- /dev/null +++ b/vla-manager-api/Dockerfile @@ -0,0 +1,53 @@ +FROM python:3.12-slim AS build + +# Install uv (mirrors dva-processing/Dockerfile style) +COPY --from=ghcr.io/astral-sh/uv:0.7 /uv /uvx /bin/ + +WORKDIR /app/ + +# Install dependencies (cached layer) +RUN \ + --mount=type=cache,target=/root/.cache/uv2 \ + --mount=type=bind,source=./vla-manager-api/pyproject.toml,target=pyproject.toml \ + uv pip install --system \ + "fastapi~=0.136.3" \ + "asyncpg>=0.30.0" \ + "pydantic>=2.10.6" \ + "pydantic-settings>=2.5.0" \ + "structlog>=25.1.0" \ + "uvicorn>=0.34.3" \ + "chevron>=0.14" + +# Copy app files +COPY ./vla-manager-api/ /app/ + +# ---------------------------------------------------------------- +FROM python:3.12-slim + +# netcat for healthcheck +RUN apt-get update && \ + apt-get install -y --no-install-recommends netcat-openbsd=1.* \ + && rm -rf /var/lib/apt/lists/ + +# Install uvicorn in the runtime stage (mirrors build stage) +COPY --from=build /usr/local/bin/uvicorn /usr/local/bin/uvicorn + +# Copy installed packages + source from build stage +COPY --from=build --chown=app:app /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=build --chown=app:app /app/src /app/src +COPY --from=build --chown=app:app /app/pyproject.toml /app/pyproject.toml + +WORKDIR /app + +ENV PYTHONPATH=/app/src \ + PYTHONUNBUFFERED=1 + +# Hand-written OpenAPI spec — served at /swagger/openapi.yaml +COPY ./docs/spec/vla-manager-api.yaml /app/openapi.yaml +ENV VLA_MANAGER_OPENAPI_FILE=/app/openapi.yaml + +# Run app via uvicorn. ``vla_manager_api.main:app`` is the FastAPI +# instance at module scope (see ``main.py``). +ENTRYPOINT ["uvicorn", "vla_manager_api.main:app", "--host", "0.0.0.0", "--port", "8000"] + +EXPOSE 8000/tcp \ No newline at end of file diff --git a/vla-manager-api/README.md b/vla-manager-api/README.md new file mode 100644 index 0000000..53cfbcc --- /dev/null +++ b/vla-manager-api/README.md @@ -0,0 +1,52 @@ +# VLA Manager API + +VLA Manager API is a FastAPI service hosted at the **Data Intermediary** that owns the +Veracity Level Agreements (VLAs) on behalf of all participants. + +## Why + +In the refactored DVA topology this is the **only** place VLAs live. Each participant's +DVA API no longer stores VLAs in its own Postgres — instead, during the synchronous +attestation flow, the DVA API calls `GET /vla/{id}` over HTTP on this service +to resolve a VLA from its ID. + +Separating VLA ownership from the attestation gateway means: +- DVA API shrinks to pure orchestration (HTTP gateway; one role) +- VLAs are authored once and shared across participants +- The VLA Manager Vue UI talks to a single dedicated backend + +## Role + +| Endpoint | Persona | Purpose | +|---|---|---| +| `GET /vla` | VLA Manager UI, admin | List all VLAs | +| `GET /vla/{id}` | DVA API, UI | Retrieve a VLA by its UUID — used during VLA resolution in the synchronous attestation flow | +| `POST /vla` | VLA Manager UI | Create a VLA from a partial ODCS payload | +| `POST /vla/from-templates` | VLA Manager UI | *Reserved (501)* — implemented in a later refactor step | +| `DELETE /vla` | Admin only | Wipe all VLAs (guarded by `VLA_MANAGER_API_KEY` bearer auth; disabled when key is empty) | + +This service intentionally does **not** do evaluation, attestation, or credential issuance — +those are concerns of `dva-processing` and the `dva-vc-manager` respectively. + +## Run locally (dev) + +```bash +cd data-veracity-main/vla-manager-api +uv sync +uv run pytest # tests (FakeVLARepo, no Postgres needed) +uv run vla-manager-api # boot the gateway on :8000 +``` + +## Run in docker-compose + +See `test-env/compose.yml` — the service is wired as `vla-manager-api` on port `9099` +(Data Intermediary) with `VLA_MANAGER_DB_URL=postgresql://postgres-vla:5432/vla`. + +## Configuration (.env) + +| Var | Default | Purpose | +|---|---|---| +| `VLA_MANAGER_DB_URL` | *(empty)* | Postgres DSN, e.g. `postgresql://vla:vla@postgres:5432/vla` | +| `VLA_MANAGER_API_KEY` | *(empty)* | Shared-secret bearer for `DELETE /vla`. When empty, the endpoint is disabled. | +| `VLA_MANAGER_API_PORT` | `8000` | Listen port | +| `VLA_MANAGER_API_LOG_LEVEL` | `INFO` | Standard Python log-level name | \ No newline at end of file diff --git a/vla-manager-api/pyproject.toml b/vla-manager-api/pyproject.toml new file mode 100644 index 0000000..61423f3 --- /dev/null +++ b/vla-manager-api/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "vla-manager-api" +version = "0.1.0" +description = "VLA Manager API — sole owner of Veracity Level Agreements, hosted at the Data Intermediary" +readme = "README.md" +authors = [{ name = "FTSRG", email = "bpeter@edu.bme.hu" }] +license = "Apache-2.0" +requires-python = ">=3.10" +dependencies = [ + "asyncpg>=0.30.0", + "fastapi~=0.136.3", + "pydantic>=2.10.6", + "pydantic-settings>=2.5.0", + "structlog>=25.1.0", + "uvicorn>=0.34.3", + "chevron>=0.14", +] +classifiers = ["Private :: Do Not Upload"] + +[project.scripts] +vla-manager-api = "vla_manager_api.main:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/vla_manager_api"] + +[dependency-groups] +dev = ["pytest>=8.3.5", "httpx>=0.27.0"] \ No newline at end of file diff --git a/vla-manager-api/src/vla_manager_api/__init__.py b/vla-manager-api/src/vla_manager_api/__init__.py new file mode 100644 index 0000000..9b7697e --- /dev/null +++ b/vla-manager-api/src/vla_manager_api/__init__.py @@ -0,0 +1,13 @@ +"""VLA Manager API — sole owner of Veracity Level Agreements. + +Hosted at the Data Intermediary. Serves the VLA authoring UI and answers +``GET /vla/{id}`` requests from each participant's DVA API during VLA +resolution in the synchronous attestation flow. + +This module deliberately does *not* perform any evaluation, attestation +or credential issuance — those are concerns of other components. VLA +Manager API owns only VLAs (and, eventually, VLA templates and the +"test requirements while building VLAs" proxy endpoint). +""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/vla-manager-api/src/vla_manager_api/auth.py b/vla-manager-api/src/vla_manager_api/auth.py new file mode 100644 index 0000000..ca73ab8 --- /dev/null +++ b/vla-manager-api/src/vla_manager_api/auth.py @@ -0,0 +1,27 @@ +"""Minimal Bearer-token auth for destructive endpoints. + +Mirrors the Kotlin guard at ``vlaRoutes.kt:129-146``: ``DELETE /vla`` +is **disabled entirely** when ``VLA_MANAGER_API_KEY`` is empty, and +requires ``Authorization: Bearer `` otherwise. + +This module is intentionally minimal — shared-secret Bearer only. It is +reused by the VLA Manager Vue UI when deleting all VLAs. +""" + +from __future__ import annotations + +from fastapi import Header, HTTPException, status + +from .config import cfg + + +def require_api_key(authorization: str | None = Header(default=None)) -> None: + """Dependency that fails-closed when no API key is configured.""" + if cfg.api_key == "": + # Gate removed: refuse all wipe attempts rather than allowing + # unauthenticated mass deletion during dev/test runs. + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "API key not configured") + + header = (authorization or "").removeprefix("Bearer ").strip() + if header != cfg.api_key: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key") \ No newline at end of file diff --git a/vla-manager-api/src/vla_manager_api/config.py b/vla-manager-api/src/vla_manager_api/config.py new file mode 100644 index 0000000..dab3f5a --- /dev/null +++ b/vla-manager-api/src/vla_manager_api/config.py @@ -0,0 +1,62 @@ +"""Runtime configuration for the VLA Manager API. + +Read from environment variables (mirrors dva-processing's config style — +plain module-level constants instead of a config dataclass, so the rest +of the codebase can ``from .config import cfg``). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from sys import stderr + + +def _truthy(value: str | None) -> bool: + return value is not None and value.lower() in {"1", "true", "yes", "on"} + + +def _log_level(value: str | None) -> int: + import logging + + return getattr(logging, (value or "INFO").upper(), logging.INFO) + + +@dataclass +class Config: + host: str = os.getenv("VLA_MANAGER_API_HOST", "0.0.0.0") + port: int = int(os.getenv("VLA_MANAGER_API_PORT", "8000")) + log_level: int = _log_level(os.getenv("VLA_MANAGER_API_LOG_LEVEL", "INFO")) + + # Postgres DSN. Required for the production (asyncpg) repository. + # Example: postgresql://vla:vla@postgres-vla:5432/vla + postgres_dsn: str = os.getenv("VLA_MANAGER_DB_URL", "") + + postgres_user: str = os.getenv("VLA_MANAGER_DB_USER", "") + postgres_password: str = os.getenv("VLA_MANAGER_DB_PASSWORD", "") + + # Optional shared-secret bearer token guarding destructive endpoints + # (DELETE /vla). When empty (default), ``DELETE /vla`` is disabled + # entirely — mirrors the dva-api guard at vlaRoutes.kt:129-146. + api_key: str = os.getenv("VLA_MANAGER_API_KEY", "") + + +cfg = Config() + + +def setup_logging() -> None: + # Defer structlog import until called so unit tests importing `config` + # don't drag structlog in (keeps test-time imports minimal). + import structlog + from structlog import make_filtering_bound_logger + from structlog.dev import ConsoleRenderer + from structlog.processors import JSONRenderer, StackInfoRenderer, TimeStamper + from structlog.stdlib import add_log_level + + shared = [add_log_level, StackInfoRenderer(), TimeStamper(fmt="iso")] + processors = shared + ([ConsoleRenderer()] if stderr.isatty() else [JSONRenderer()]) + structlog.configure( + processors=processors, + context_class=dict, + wrapper_class=make_filtering_bound_logger(cfg.log_level), + ) \ No newline at end of file diff --git a/vla-manager-api/src/vla_manager_api/dependencies.py b/vla-manager-api/src/vla_manager_api/dependencies.py new file mode 100644 index 0000000..83570da --- /dev/null +++ b/vla-manager-api/src/vla_manager_api/dependencies.py @@ -0,0 +1,74 @@ +"""FastAPI dependency providers. + +Kept separate from :mod:`.main` and :mod:`.routes` to avoid circular +imports — :mod:`.main` imports :mod:`.routes` which imports +:mod:`.dependencies`. Tests override providers via +``app.dependency_overrides``. +""" + +from __future__ import annotations + +import logging +import os + +from .config import cfg +from .repo import FakeTemplateRepo, FakeVLARepo, TemplateRepo, VLARepo + +logger = logging.getLogger(__name__) + +# Lazy singleton — populated on first request. Tests override via +# ``app.dependency_overrides[get_repo] = lambda: FakeVLARepo()``. +_repo_singleton: VLARepo | None = None + + +async def get_repo() -> VLARepo: + global _repo_singleton + if _repo_singleton is None: + if cfg.postgres_dsn: + from .main import _build_production_repo + + _repo_singleton = await _build_production_repo() + else: + # Dev convenience: boot with an in-memory repo so a bare + # ``uvicorn vla_manager_api.main:app`` works without a + # Postgres. State is lost on restart, so do NOT use this + # mode in production — set VLA_MANAGER_DB_URL. + logger.warning( + "VLA_MANAGER_DB_URL is not set — falling back to FakeVLARepo " + "(in-memory). State will be lost on restart. Configure " + "VLA_MANAGER_DB_URL for production use." + ) + _repo_singleton = FakeVLARepo() + return _repo_singleton + + +def install_repo_for_tests(repo: VLARepo) -> None: + """Bypass the lazy path and inject a fixed repo for tests.""" + global _repo_singleton + _repo_singleton = repo + + +# --- Template repo (same lazy-singleton pattern) --- +_template_repo_singleton: TemplateRepo | None = None + + +async def get_template_repo() -> TemplateRepo: + global _template_repo_singleton + if _template_repo_singleton is None: + if cfg.postgres_dsn: + from .main import _build_production_template_repo + + _template_repo_singleton = await _build_production_template_repo() + else: + logger.warning( + "VLA_MANAGER_DB_URL is not set — falling back to FakeTemplateRepo " + "(in-memory). State will be lost on restart." + ) + _template_repo_singleton = FakeTemplateRepo() + return _template_repo_singleton + + +def install_template_repo_for_tests(repo: TemplateRepo) -> None: + """Bypass the lazy path and inject a fixed template repo for tests.""" + global _template_repo_singleton + _template_repo_singleton = repo \ No newline at end of file diff --git a/vla-manager-api/src/vla_manager_api/main.py b/vla-manager-api/src/vla_manager_api/main.py new file mode 100644 index 0000000..0bb7349 --- /dev/null +++ b/vla-manager-api/src/vla_manager_api/main.py @@ -0,0 +1,155 @@ +"""FastAPI application factory and CLI entrypoint. + +The application is wired so the repository implementation is resolved +through FastAPI's dependency-injection system. In production the +async-backed ``PgVLARepo`` is constructed on startup (via the lazy +``dependencies.get_repo``); in tests the caller swaps it via +``app.dependency_overrides[get_repo]``. +""" + +from __future__ import annotations + +import logging +import os + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, PlainTextResponse + +from .config import cfg, setup_logging +from .routes import router +from .template_routes import router as template_router + +_SWAGGER_UI_HTML = """\ + + + + VLA Manager API — Swagger UI + + + + +
+ + + + + +""" + + +async def _build_production_repo(): + """Construct the async-backed repository. + + Requires ``cfg.postgres_dsn`` to be set (env ``VLA_MANAGER_DB_URL``). + Must be awaited from within the running event loop (e.g. the + ``get_repo`` dependency) — never wrapped in ``asyncio.run()``, which + raises ``RuntimeError`` when a loop is already running. + """ + import asyncpg + + from .repo import PgVLARepo + + if not cfg.postgres_dsn: + raise RuntimeError( + "VLA_MANAGER_DB_URL is not set — cannot boot PgVLARepo. " + "Either set it or override the get_repo dependency for tests." + ) + + pool = await asyncpg.create_pool(dsn=cfg.postgres_dsn, min_size=1, max_size=4) + repo = PgVLARepo(pool) + await repo._ensure_schema() + return repo + + +async def _build_production_template_repo(): + """Construct the async-backed Template repository. + + Shares the same asyncpg pool as the VLA repo but owns separate + ``templates`` + ``evaluation_methods`` tables. + """ + import asyncpg + + from .repo import PgTemplateRepo + + if not cfg.postgres_dsn: + raise RuntimeError( + "VLA_MANAGER_DB_URL is not set — cannot boot PgTemplateRepo. " + "Either set it or override the get_template_repo dependency for tests." + ) + + pool = await asyncpg.create_pool(dsn=cfg.postgres_dsn, min_size=1, max_size=4) + repo = PgTemplateRepo(pool) + await repo._ensure_schema() + return repo + + +def create_app() -> FastAPI: + setup_logging() + app = FastAPI( + title="VLA Manager API", + description=( + "Sole owner of Veracity Level Agreements, hosted at the Data " + "Intermediary. Serves ``GET /vla/{id}`` to each participant's " + "DVA API during attestation (steps 2-3 of the synchronous flow) " + "and serves the VLA authoring UI for VLA CRUD." + ), + version="0.1.0", + # Disable auto-generated docs — hand-written spec is served at /swagger + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + app.include_router(router) + app.include_router(template_router) + + @app.get("/swagger", response_class=HTMLResponse, include_in_schema=False) + async def swagger_ui() -> HTMLResponse: + """Serve the Swagger UI loaded from the hand-written OpenAPI spec.""" + return HTMLResponse(content=_SWAGGER_UI_HTML) + + @app.get("/swagger/openapi.yaml", response_class=PlainTextResponse, include_in_schema=False) + async def swagger_spec() -> PlainTextResponse: + """Serve the hand-written OpenAPI spec YAML from disk.""" + spec_path = os.environ.get("VLA_MANAGER_OPENAPI_FILE", "/app/openapi.yaml") + try: + with open(spec_path, "r", encoding="utf-8") as fh: + content = fh.read() + except FileNotFoundError: + return PlainTextResponse(content="# spec file not found", status_code=404) + return PlainTextResponse(content=content, media_type="application/yaml") + + return app + + +# Module-level app — used by ``uvicorn vla_manager_api.main:app`` and by +# the ``TestClient`` in ``tests/test_vla_crud.py``. +app = create_app() + + +def _level_to_str(level: int) -> str: + for name, val in logging._levelToName.items(): + if val == level: + return name.lower() + return "info" + + +def cli() -> None: + """uvicorn entrypoint (see ``[project.scripts]`` in pyproject.toml).""" + import uvicorn + + uvicorn.run( + "vla_manager_api.main:app", + host=cfg.host, + port=cfg.port, + log_level=_level_to_str(cfg.log_level), + ) \ No newline at end of file diff --git a/vla-manager-api/src/vla_manager_api/models.py b/vla-manager-api/src/vla_manager_api/models.py new file mode 100644 index 0000000..200a824 --- /dev/null +++ b/vla-manager-api/src/vla_manager_api/models.py @@ -0,0 +1,136 @@ +"""Pydantic v2 schemas accepted/returned by the VLA Manager API. + +These mirror the Kotlin DTOs byte-for-byte so the existing VLA Manager +Vue UI and consumers of the prior ``dva-api /vla`` routes interoperate +without contract changes. + +Reference: +- model/src/main/kotlin/hu/bme/mit/ftsrg/dva/vla/VLANew.kt +- model/src/main/kotlin/hu/bme/mit/ftsrg/odcs/DataQuality.kt +- model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/IDDTO.kt +- model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/ErrDTO.kt +""" + +from __future__ import annotations + +from typing import Any, Optional +from uuid import UUID, uuid4 + +from pydantic import BaseModel, ConfigDict, Field + + +class DataQuality(BaseModel): + """A single quality requirement (ODCS DataQuality fragment).""" + + engine: str + implementation: str + + +class VLANew(BaseModel): + """Body of ``POST /vla``. All fields optional — partial ODCS payload. + + The ``schema`` field is renamed via alias because ``schema`` is a + reserved attribute name on pydantic BaseModel. Inputs and outputs + use the JSON key ``schema`` transparently. + """ + + model_config = ConfigDict(populate_by_name=True) + + description: Optional[str] = None + servers: Optional[list[Any]] = None + schema_: Optional[dict[str, Any]] = Field(default=None, alias="schema") + quality: Optional[list[DataQuality]] = None + price: Optional[dict[str, Any]] = None + team: Optional[list[Any]] = None + roles: Optional[list[Any]] = None + slaProperties: Optional[list[Any]] = None + support: Optional[list[Any]] = None + tags: Optional[list[Any]] = None + + +class IDDTO(BaseModel): + id: UUID + + +class TemplateInstantiation(BaseModel): + """One entry in ``VLANewFromTemplates.qualityTemplates`` — a template + id plus the model dict to render it with.""" + + model_config = ConfigDict(populate_by_name=True) + + id: UUID + model: dict[str, Any] + + +class VLANewFromTemplates(VLANew): + """Body of ``POST /vla/from-templates``. Extends VLANew with a + ``qualityTemplates`` array whose entries are rendered and merged + into the VLA's ``quality`` array before persistence.""" + + model_config = ConfigDict(populate_by_name=True) + + quality_templates: list[TemplateInstantiation] = Field( + alias="qualityTemplates", default_factory=list + ) + + +class ErrDTO(BaseModel): + type: str + title: str + + +# --------------------------------------------------------------------------- +# Template models — ported from the deleted Kotlin ``Template.kt`` (commit +# ba876ff~1). Wire format is camelCase to remain byte-compatible with the +# existing VLA Manager Vue UI and the OpenAPI spec. +# --------------------------------------------------------------------------- + +class EvaluationMethod(BaseModel): + """Renderable evaluation method inside a Template.""" + + model_config = ConfigDict(populate_by_name=True) + + engine: str + variable_schema: dict[str, Any] = Field(alias="variableSchema") + implementation_template: str = Field(alias="implementationTemplate") + + +class TemplateNew(BaseModel): + """Body of ``POST /template`` — create a new template (no id).""" + + model_config = ConfigDict(populate_by_name=True) + + name: str + description: Optional[str] = None + criterion_type: str = Field(alias="criterionType") + target_aspect: str = Field(alias="targetAspect") + evaluation_method: EvaluationMethod = Field(alias="evaluationMethod") + + +class TemplatePatch(BaseModel): + """Body of ``PATCH /template/{id}`` — partial update. ``id`` must + match the path parameter.""" + + model_config = ConfigDict(populate_by_name=True) + + id: UUID + name: Optional[str] = None + description: Optional[str] = None + criterion_type: Optional[str] = Field(default=None, alias="criterionType") + target_aspect: Optional[str] = Field(default=None, alias="targetAspect") + evaluation_method: Optional[EvaluationMethod] = Field( + default=None, alias="evaluationMethod" + ) + + +class Template(BaseModel): + """Full template representation returned by GET endpoints.""" + + model_config = ConfigDict(populate_by_name=True) + + id: UUID + name: str + description: Optional[str] = None + criterion_type: str = Field(alias="criterionType") + target_aspect: str = Field(alias="targetAspect") + evaluation_method: EvaluationMethod = Field(alias="evaluationMethod") \ No newline at end of file diff --git a/vla-manager-api/src/vla_manager_api/repo.py b/vla-manager-api/src/vla_manager_api/repo.py new file mode 100644 index 0000000..363a952 --- /dev/null +++ b/vla-manager-api/src/vla_manager_api/repo.py @@ -0,0 +1,356 @@ +"""VLA repository — pluggable persistence for Veracity Level Agreements. + +Two implementations: + +* :class:`FakeVLARepo` — in-memory map used in tests (mirrors the Kotlin + ``FakeVLARepo``). No external dependencies. +* :class:`PgVLARepo` — async-backed PostgreSQL repository via asyncpg. + The repository owns the ``vlas`` table on the Data Intermediary's + Postgres. Each VLA is stored as the raw ODCS JSON text in + ``odcs_json``; the UUID primary key is generated on the application + side (avoids any pgcrypto/extension dependency) and round-trips + verbatim. The ``id`` field is injected into the returned JSON object + on read — exactly as the Kotlin ``toModel()`` helper does. + +The interface is async because the production path uses asyncpg; the +fake returns plain values for ease of testing. +""" + +from __future__ import annotations + +import json +from typing import Any, Optional, Protocol, Sequence +from uuid import UUID, uuid4 + +__all__ = ["VLARepo", "FakeVLARepo", "PgVLARepo", + "TemplateRepo", "FakeTemplateRepo", "PgTemplateRepo", + "render_template"] + + +class VLARepo(Protocol): + """Minimal contract for VLA persistence.""" + + async def all(self) -> list[dict[str, Any]]: ... + + async def by_id(self, id: UUID) -> Optional[dict[str, Any]]: ... + + async def add(self, vla: dict[str, Any]) -> Optional[UUID]: ... + + async def remove_all(self) -> None: ... + + +def _with_id(odcs_text: str, id: UUID) -> dict[str, Any]: + """Inject the ``id`` field into a deserialised ODCS object.""" + obj: dict[str, Any] = json.loads(odcs_text) + obj["id"] = str(id) + return obj + + +class FakeVLARepo: + """In-memory VLA repository for tests.""" + + def __init__(self) -> None: + self._vlas: dict[UUID, str] = {} + + async def all(self) -> list[dict[str, Any]]: + return [_with_id(text, id) for id, text in self._vlas.items()] + + async def by_id(self, id: UUID) -> Optional[dict[str, Any]]: + text = self._vlas.get(id) + return _with_id(text, id) if text is not None else None + + async def add(self, vla: dict[str, Any]) -> Optional[UUID]: + id = uuid4() + # Strip any caller-supplied "id" before persisting — persistence + # owns the id, not the caller. + vla = {k: v for k, v in vla.items() if k != "id"} + self._vlas[id] = json.dumps(vla) + return id + + async def remove_all(self) -> None: + self._vlas.clear() + + +class PgVLARepo: + """Async-backed PostgreSQL VLA repository using asyncpg. + + Owns the ``vlas`` table. Constructed with an ``asyncpg.Pool`` (see + :mod:`vla_manager_api.main` for pool creation). Columns mirror the + Kotlin ``VLAsTable`` (``db/vlaMapping.kt:12-14``): + + ``id UUID PRIMARY KEY``, ``odcs_json TEXT NOT NULL``. + """ + + def __init__(self, pool): # type: ignore[no-untyped-def] + self._pool = pool + + async def _ensure_schema(self) -> None: + async with self._pool.acquire() as conn: + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS vlas ( + id UUID PRIMARY KEY, + odcs_json TEXT NOT NULL + ) + """ + ) + + async def all(self) -> list[dict[str, Any]]: + async with self._pool.acquire() as conn: + rows = await conn.fetch("SELECT id, odcs_json FROM vlas") + return [_with_id(r["odcs_json"], r["id"]) for r in rows] + + async def by_id(self, id: UUID) -> Optional[dict[str, Any]]: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, odcs_json FROM vlas WHERE id = $1", id + ) + return _with_id(row["odcs_json"], row["id"]) if row is not None else None + + async def add(self, vla: dict[str, Any]) -> Optional[UUID]: + id = uuid4() + vla = {k: v for k, v in vla.items() if k != "id"} + async with self._pool.acquire() as conn: + await conn.execute( + "INSERT INTO vlas (id, odcs_json) VALUES ($1, $2)", + id, + json.dumps(vla), + ) + return id + + async def remove_all(self) -> None: + async with self._pool.acquire() as conn: + await conn.execute("DELETE FROM vlas") + + +# --------------------------------------------------------------------------- +# Template repository — ported from deleted Kotlin ``PgTemplateRepo.kt`` +# (commit ba876ff~1). Owns two tables: ``templates`` + ``evaluation_methods`` +# (1:1). The render helper uses Handlebars-compatible ``{{var}}`` syntax via +# the ``chevron`` package, byte-compatible with the old Kotlin Handlebars +# output. +# --------------------------------------------------------------------------- + + +def render_template(implementation_template: str, model: dict[str, Any]) -> str: + """Render a Handlebars ``{{var}}`` template string with a model dict. + + Mirrors the deleted Kotlin ``Template.render`` (service/templates.kt). + Uses the ``chevron`` library for Mustache/Handlebars fidelity. + """ + import chevron + + return chevron.render(implementation_template, model) + + +class TemplateRepo(Protocol): + """Minimal contract for Template persistence.""" + + async def all(self) -> list[dict[str, Any]]: ... + + async def by_id(self, id: UUID) -> Optional[dict[str, Any]]: ... + + async def add(self, template: dict[str, Any]) -> Optional[UUID]: ... + + async def update(self, id: UUID, patch: dict[str, Any]) -> Optional[dict[str, Any]]: ... + + async def remove(self, id: UUID) -> bool: ... + + async def remove_all(self) -> None: ... + + +class FakeTemplateRepo: + """In-memory Template repository for tests.""" + + def __init__(self) -> None: + self._templates: dict[UUID, dict[str, Any]] = {} + + async def all(self) -> list[dict[str, Any]]: + # Inject id into returned dict (mirror PgTemplateRepo behaviour) + return [{**t, "id": str(tid)} for tid, t in self._templates.items()] + + async def by_id(self, id: UUID) -> Optional[dict[str, Any]]: + t = self._templates.get(id) + return {**t, "id": str(id)} if t is not None else None + + async def add(self, template: dict[str, Any]) -> Optional[UUID]: + raw_id = template.get("id") + if raw_id is not None: + id = UUID(str(raw_id)) + else: + id = uuid4() + stored = {k: v for k, v in template.items() if k != "id"} + self._templates[id] = stored + return id + + async def update(self, id: UUID, patch: dict[str, Any]) -> Optional[dict[str, Any]]: + existing = self._templates.get(id) + if existing is None: + return None + for k, v in patch.items(): + if v is not None: + existing[k] = v + return {**existing, "id": str(id)} + + async def remove(self, id: UUID) -> bool: + return self._templates.pop(id, None) is not None + + async def remove_all(self) -> None: + self._templates.clear() + + +class PgTemplateRepo: + """Async-backed PostgreSQL Template repository using asyncpg. + + Owns the ``templates`` + ``evaluation_methods`` tables (1:1). + Columns mirror the deleted Kotlin ``templateMapping.kt``. + """ + + def __init__(self, pool): # type: ignore[no-untyped-def] + self._pool = pool + + async def _ensure_schema(self) -> None: + async with self._pool.acquire() as conn: + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS evaluation_methods ( + id UUID PRIMARY KEY, + engine VARCHAR(255) NOT NULL, + variable_schema TEXT NOT NULL, + implementation_template TEXT NOT NULL + ) + """ + ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS templates ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + criterion_type VARCHAR(255) NOT NULL, + target_aspect VARCHAR(255) NOT NULL, + evaluation_method_id UUID NOT NULL + REFERENCES evaluation_methods(id) ON DELETE CASCADE + ) + """ + ) + + def _row_to_dict(self, row) -> dict[str, Any]: # type: ignore[no-untyped-def] + return { + "id": str(row["id"]), + "name": row["name"], + "description": row["description"], + "criterionType": row["criterion_type"], + "targetAspect": row["target_aspect"], + "evaluationMethod": { + "engine": row["engine"], + "variableSchema": json.loads(row["variable_schema"]), + "implementationTemplate": row["implementation_template"], + }, + } + + async def all(self) -> list[dict[str, Any]]: + async with self._pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT t.id, t.name, t.description, t.criterion_type, + t.target_aspect, em.engine, em.variable_schema, + em.implementation_template + FROM templates t + JOIN evaluation_methods em ON t.evaluation_method_id = em.id + """ + ) + return [self._row_to_dict(r) for r in rows] + + async def by_id(self, id: UUID) -> Optional[dict[str, Any]]: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT t.id, t.name, t.description, t.criterion_type, + t.target_aspect, em.engine, em.variable_schema, + em.implementation_template + FROM templates t + JOIN evaluation_methods em ON t.evaluation_method_id = em.id + WHERE t.id = $1 + """, + id, + ) + return self._row_to_dict(row) if row is not None else None + + async def add(self, template: dict[str, Any]) -> Optional[UUID]: + em = template["evaluationMethod"] + em_id = uuid4() + t_id = uuid4() + async with self._pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO evaluation_methods (id, engine, variable_schema, implementation_template) + VALUES ($1, $2, $3, $4) + """, + em_id, + em["engine"], + json.dumps(em["variableSchema"]), + em["implementationTemplate"], + ) + await conn.execute( + """ + INSERT INTO templates (id, name, description, criterion_type, target_aspect, evaluation_method_id) + VALUES ($1, $2, $3, $4, $5, $6) + """, + t_id, + template["name"], + template.get("description"), + template["criterionType"], + template["targetAspect"], + em_id, + ) + return t_id + + async def update(self, id: UUID, patch: dict[str, Any]) -> Optional[dict[str, Any]]: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT evaluation_method_id FROM templates WHERE id = $1", id + ) + if row is None: + return None + em_id = row["evaluation_method_id"] + if patch.get("name") is not None: + await conn.execute("UPDATE templates SET name = $2 WHERE id = $1", id, patch["name"]) + if patch.get("description") is not None: + await conn.execute("UPDATE templates SET description = $2 WHERE id = $1", id, patch["description"]) + if patch.get("criterionType") is not None: + await conn.execute("UPDATE templates SET criterion_type = $2 WHERE id = $1", id, patch["criterionType"]) + if patch.get("targetAspect") is not None: + await conn.execute("UPDATE templates SET target_aspect = $2 WHERE id = $1", id, patch["targetAspect"]) + em_patch = patch.get("evaluationMethod") + if em_patch is not None: + await conn.execute( + "UPDATE evaluation_methods SET engine = $2 WHERE id = $1", + em_id, em_patch["engine"], + ) + await conn.execute( + "UPDATE evaluation_methods SET variable_schema = $2 WHERE id = $1", + em_id, json.dumps(em_patch["variableSchema"]), + ) + await conn.execute( + "UPDATE evaluation_methods SET implementation_template = $2 WHERE id = $1", + em_id, em_patch["implementationTemplate"], + ) + return await self.by_id(id) + + async def remove(self, id: UUID) -> bool: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT evaluation_method_id FROM templates WHERE id = $1", id + ) + if row is None: + return False + em_id = row["evaluation_method_id"] + await conn.execute("DELETE FROM templates WHERE id = $1", id) + await conn.execute("DELETE FROM evaluation_methods WHERE id = $1", em_id) + return True + + async def remove_all(self) -> None: + async with self._pool.acquire() as conn: + await conn.execute("DELETE FROM templates") + await conn.execute("DELETE FROM evaluation_methods") \ No newline at end of file diff --git a/vla-manager-api/src/vla_manager_api/routes.py b/vla-manager-api/src/vla_manager_api/routes.py new file mode 100644 index 0000000..71d06ec --- /dev/null +++ b/vla-manager-api/src/vla_manager_api/routes.py @@ -0,0 +1,111 @@ +"""FastAPI routes for the VLA Manager API. + +VLA CRUD routes plus POST /vla/from-templates which fetches VLA +templates, renders each with a model, and merges the rendered quality +requirements into the VLA before persistence. + +DELETE /vla is guarded by :func:`.auth.require_api_key`. +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status + +from .auth import require_api_key +from .dependencies import get_repo, get_template_repo +from .models import ErrDTO, IDDTO, VLANew, VLANewFromTemplates +from .repo import TemplateRepo, VLARepo, render_template + + +router = APIRouter() + + +def _wrap_vla(vla_req: VLANew) -> dict[str, Any]: + """Wrap a partial ODCS payload with the boilerplate headers, mirroring + the Kotlin ``buildJsonObject`` wrapper at ``vlaRoutes.kt:50-67``.""" + base: dict[str, Any] = { + "apiVersion": "v3.0.2", + "kind": "DataContract", + "version": "0.1.0", + "status": "active", + } + data = vla_req.model_dump(exclude_none=True, by_alias=True, mode="json") + base.update(data) + return base + + +@router.get("/vla") +async def list_vlas(repo: VLARepo = Depends(get_repo)) -> list[dict[str, Any]]: + return await repo.all() + + +@router.get("/vla/{id}") +async def get_vla(id: UUID, repo: VLARepo = Depends(get_repo)) -> dict[str, Any]: + vla = await repo.by_id(id) + if vla is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + return vla + + +@router.post("/vla", status_code=status.HTTP_201_CREATED, response_model=IDDTO) +async def create_vla(vla_req: VLANew, repo: VLARepo = Depends(get_repo)) -> IDDTO: + vla = _wrap_vla(vla_req) + new_id = await repo.add(vla) + if new_id is None: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrDTO(type="UNKNOWN", title="Failed to create VLA").model_dump(), + ) + return IDDTO(id=new_id) + + +@router.post("/vla/from-templates", status_code=status.HTTP_201_CREATED, response_model=IDDTO) +async def create_vla_from_templates( + vla_req: VLANewFromTemplates, + repo: VLARepo = Depends(get_repo), + template_repo: TemplateRepo = Depends(get_template_repo), +) -> IDDTO: + base_vla = _wrap_vla(vla_req) + base_vla.pop("qualityTemplates", None) + + rendered_quality: list[dict[str, Any]] = [] + for qt in vla_req.quality_templates: + template = await template_repo.by_id(qt.id) + if template is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + em = template["evaluationMethod"] + try: + implementation = render_template(em["implementationTemplate"], qt.model) + except Exception: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=ErrDTO( + type="BAD_REQUEST", + title=f"Failed to render template {qt.id}", + ).model_dump(), + ) + rendered_quality.append( + {"engine": em["engine"], "implementation": implementation} + ) + + existing_quality = base_vla.get("quality") or [] + base_vla["quality"] = list(existing_quality) + rendered_quality + + new_id = await repo.add(base_vla) + if new_id is None: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrDTO(type="UNKNOWN", title="Failed to create VLA").model_dump(), + ) + return IDDTO(id=new_id) + + +@router.delete("/vla", status_code=status.HTTP_204_NO_CONTENT) +async def delete_all_vlas( + _: None = Depends(require_api_key), repo: VLARepo = Depends(get_repo) +) -> None: + await repo.remove_all() + return None \ No newline at end of file diff --git a/vla-manager-api/src/vla_manager_api/template_routes.py b/vla-manager-api/src/vla_manager_api/template_routes.py new file mode 100644 index 0000000..c1aa988 --- /dev/null +++ b/vla-manager-api/src/vla_manager_api/template_routes.py @@ -0,0 +1,120 @@ +"""FastAPI routes for VLA Template CRUD. + +Ported from the deleted Kotlin ``templateRoutes.kt`` (commit ba876ff~1). +Seven routes — byte-compatible with the old dva-api contract: + +* ``GET /template`` — list all templates +* ``POST /template`` — create a template +* ``GET /template/{id}`` — fetch a template by id +* ``PATCH /template/{id}`` — partial update (id in body must match path) +* ``DELETE /template/{id}`` — delete one template +* ``DELETE /template`` — delete all (dev, API-key guarded) +* ``POST /template/{id}/render`` — render the Handlebars template with a model +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel + +from .auth import require_api_key +from .dependencies import get_template_repo +from .models import ErrDTO, IDDTO, Template, TemplateNew, TemplatePatch +from .repo import TemplateRepo, render_template + + +router = APIRouter() + + +class RenderResult(BaseModel): + """Result of rendering a template — a single DataQuality fragment.""" + + engine: str + implementation: str + + +@router.get("/template", response_model=list[Template]) +async def list_templates(repo: TemplateRepo = Depends(get_template_repo)) -> list[dict[str, Any]]: + return await repo.all() + + +@router.post("/template", status_code=status.HTTP_201_CREATED, response_model=IDDTO) +async def create_template( + template_req: TemplateNew, repo: TemplateRepo = Depends(get_template_repo) +) -> IDDTO: + template = template_req.model_dump(by_alias=True, exclude_none=True) + new_id = await repo.add(template) + if new_id is None: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrDTO(type="UNKNOWN", title="Failed to create template").model_dump(), + ) + return IDDTO(id=new_id) + + +@router.get("/template/{id}", response_model=Template) +async def get_template(id: UUID, repo: TemplateRepo = Depends(get_template_repo)) -> dict[str, Any]: + template = await repo.by_id(id) + if template is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + return template + + +@router.patch("/template/{id}", response_model=Template) +async def update_template( + id: UUID, + patch: TemplatePatch, + repo: TemplateRepo = Depends(get_template_repo), +) -> dict[str, Any]: + if id != patch.id: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=ErrDTO( + type="BAD_REQUEST", + title="ID path parameter does not match ID in body", + ).model_dump(), + ) + patch_dict = patch.model_dump(by_alias=True, exclude_none=True) + updated = await repo.update(id, patch_dict) + if updated is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + return updated + + +@router.delete("/template/{id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_template(id: UUID, repo: TemplateRepo = Depends(get_template_repo)) -> None: + if not await repo.remove(id): + raise HTTPException(status.HTTP_404_NOT_FOUND) + return None + + +@router.delete("/template", status_code=status.HTTP_204_NO_CONTENT) +async def delete_all_templates( + _: None = Depends(require_api_key), + repo: TemplateRepo = Depends(get_template_repo), +) -> None: + await repo.remove_all() + return None + + +@router.post("/template/{id}/render", response_model=RenderResult) +async def render_template_route( + id: UUID, + model: dict[str, Any], + repo: TemplateRepo = Depends(get_template_repo), +) -> RenderResult: + template = await repo.by_id(id) + if template is None: + raise HTTPException(status.HTTP_404_NOT_FOUND) + em = template["evaluationMethod"] + try: + rendered = render_template(em["implementationTemplate"], model) + except Exception: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=ErrDTO(type="BAD_REQUEST", title="Failed to render template").model_dump(), + ) + return RenderResult(engine=em["engine"], implementation=rendered) \ No newline at end of file diff --git a/vla-manager-api/tests/__init__.py b/vla-manager-api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vla-manager-api/tests/test_vla_crud.py b/vla-manager-api/tests/test_vla_crud.py new file mode 100644 index 0000000..9e6380d --- /dev/null +++ b/vla-manager-api/tests/test_vla_crud.py @@ -0,0 +1,189 @@ +"""Unit tests for the VLA Manager API — VLA CRUD happy path. + +Mirrors the Kotlin ``VLARoutesTest`` contract (the four operations it +covers): list, get-by-id, create, get-not-found. Adds an explicit +``DELETE /vla`` test that exercises the fail-closed 401 path when no +API key is configured. + +The tests override the ``get_repo`` dependency with an in-memory +``FakeVLARepo`` so no Postgres is required. +""" + +from __future__ import annotations + +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient + +from vla_manager_api.dependencies import get_repo, get_template_repo +from vla_manager_api.main import create_app +from vla_manager_api.repo import FakeTemplateRepo, FakeVLARepo + + +@pytest.fixture +def fake_repo() -> FakeVLARepo: + return FakeVLARepo() + + +@pytest.fixture +def fake_template_repo() -> FakeTemplateRepo: + return FakeTemplateRepo() + + +@pytest.fixture +def client( + fake_repo: FakeVLARepo, + fake_template_repo: FakeTemplateRepo, +) -> TestClient: + app = create_app() + app.dependency_overrides[get_repo] = lambda: fake_repo + app.dependency_overrides[get_template_repo] = lambda: fake_template_repo + return TestClient(app) + + +def test_list_vlas_empty_when_nothing_created(client: TestClient) -> None: + r = client.get("/vla") + assert r.status_code == 200 + assert r.json() == [] + + +def test_get_vla_not_found(client: TestClient) -> None: + r = client.get("/vla/00000000-0000-0000-0000-000000000001") + assert r.status_code == 404 + + +def test_create_vla_returns_id_and_appears_in_subsequent_gets( + client: TestClient, +) -> None: + payload = { + "description": "Test VLA", + "quality": [ + {"engine": "JQ", "implementation": "{ success: true }"}, + ], + } + r = client.post("/vla", json=payload) + assert r.status_code == 201 + body = r.json() + + new_id = UUID(body["id"]) + + # The persisted VLA carries the wrapper boilerplate + injected id. + r2 = client.get(f"/vla/{new_id}") + assert r2.status_code == 200 + persisted = r2.json() + assert persisted["id"] == str(new_id) + assert persisted["description"] == "Test VLA" + assert persisted["apiVersion"] == "v3.0.2" + assert persisted["kind"] == "DataContract" + assert persisted["version"] == "0.1.0" + assert persisted["status"] == "active" + assert persisted["quality"] == [ + {"engine": "JQ", "implementation": "{ success: true }"} + ] + + +def test_create_vla_with_minimal_body_persists(client: TestClient) -> None: + r = client.post("/vla", json={}) + assert r.status_code == 201 + new_id = UUID(r.json()["id"]) + + persisted = client.get(f"/vla/{new_id}").json() + # Only the wrapper boilerplate + id should be present. + assert persisted["apiVersion"] == "v3.0.2" + assert persisted["kind"] == "DataContract" + assert "description" not in persisted + + +def test_created_vla_then_listed(client: TestClient) -> None: + # Create one + r = client.post("/vla", json={"description": "My first VLA"}) + assert r.status_code == 201 + new_id = UUID(r.json()["id"]) + + # List shows it + listing = client.get("/vla").json() + assert len(listing) == 1 + assert listing[0]["id"] == str(new_id) + assert listing[0]["description"] == "My first VLA" + + +def test_delete_all_unauthorised_when_no_api_key(client: TestClient) -> None: + r = client.delete("/vla") + assert r.status_code == 401 + + +def test_vla_id_is_a_real_uuid_v4(client: TestClient) -> None: + r = client.post("/vla", json={"description": "x"}) + new_id = UUID(r.json()["id"]) + # Version nibble of a UUIDv4 is 4 in the 13th hex digit. + assert str(new_id)[14] == "4" + + +def test_vla_from_templates_returns_404_for_missing_template( + client: TestClient, + fake_template_repo: FakeTemplateRepo, +) -> None: + r = client.post( + "/vla/from-templates", + json={ + "qualityTemplates": [ + {"id": "00000000-0000-0000-0000-000000000099", "model": {}} + ] + }, + ) + assert r.status_code == 404 + + +def test_vla_from_templates_creates_vla_with_rendered_quality( + client: TestClient, + fake_template_repo: FakeTemplateRepo, +) -> None: + import asyncio + template_id = "3c58c2fd-6d7a-4953-9f76-7c71fc3ac7e2" + asyncio.run(fake_template_repo.add( + { + "id": template_id, + "name": "JQ check", + "criterionType": "process", + "targetAspect": "field", + "evaluationMethod": { + "engine": "JQ", + "variableSchema": {"value": {"type": "string"}}, + "implementationTemplate": ".value == \"ok\"", + }, + } + )) + r = client.post( + "/vla/from-templates", + json={ + "description": "rendered VLA", + "qualityTemplates": [ + {"id": template_id, "model": {"value": "ok"}} + ], + }, + ) + assert r.status_code == 201 + new_id = UUID(r.json()["id"]) + vla = client.get(f"/vla/{new_id}").json() + assert vla["description"] == "rendered VLA" + assert len(vla["quality"]) == 1 + assert vla["quality"][0]["engine"] == "JQ" + + +def test_create_vla_with_schema_field_round_trips(client: TestClient) -> None: + # The JSON key is literally ``schema`` (not ``schema_``); pydantic + # field alias must accept it and persist it under that key. + payload = { + "description": "with-schema", + "schema": {"name": "xapi_statement", "logicalType": "object"}, + } + r = client.post("/vla", json=payload) + assert r.status_code == 201 + new_id = UUID(r.json()["id"]) + + persisted = client.get(f"/vla/{new_id}").json() + assert persisted["description"] == "with-schema" + assert persisted["schema"] == {"name": "xapi_statement", "logicalType": "object"} + # The internal pydantic field name ``schema_`` must never leak out. + assert "schema_" not in persisted \ No newline at end of file diff --git a/vla-manager-api/uv.lock b/vla-manager-api/uv.lock new file mode 100644 index 0000000..040a1be --- /dev/null +++ b/vla-manager-api/uv.lock @@ -0,0 +1,564 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" }, + { url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" }, + { url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +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/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[[package]] +name = "vla-manager-api" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "structlog" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "fastapi", specifier = "~=0.136.3" }, + { name = "pydantic", specifier = ">=2.10.6" }, + { name = "pydantic-settings", specifier = ">=2.5.0" }, + { name = "structlog", specifier = ">=25.1.0" }, + { name = "uvicorn", specifier = ">=0.34.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.3.5" }, +]