From 5073d8903373766eb72a6ca83f7b926c31b5fe87 Mon Sep 17 00:00:00 2001 From: febux Date: Wed, 12 Aug 2026 21:29:36 +0300 Subject: [PATCH] Init commit --- .dockerignore | 23 + .env.example | 74 + .gitignore | 35 + .python-version | 1 + Makefile | 162 + README.md | 152 + alembic.ini | 40 + docker-compose.yml | 299 ++ docs/ARCHITECTURE.md | 1288 ++++++++ docs/BUSINESS_IDEA.md | 96 + docs/DEPLOY.md | 546 ++++ docs/IMPLEMENTATION_PLAN.md | 338 +++ docs/TICKETS.md | 216 ++ migrations/env.py | 66 + migrations/script.py.mako | 27 + migrations/versions/0001_initial.py | 234 ++ migrations/versions/0002_api_keys.py | 111 + pyproject.toml | 152 + src/contract_check/__init__.py | 11 + src/contract_check/__main__.py | 6 + src/contract_check/api/__main__.py | 69 + src/contract_check/api/app.py | 96 + src/contract_check/api/deps.py | 324 ++ src/contract_check/api/middleware.py | 47 + src/contract_check/api/routes/README.md | 397 +++ src/contract_check/api/routes/__init__.py | 1 + src/contract_check/api/routes/auth.py | 148 + src/contract_check/api/routes/b2b.py | 294 ++ src/contract_check/api/routes/documents.py | 26 + src/contract_check/api/routes/health.py | 24 + src/contract_check/api/routes/me.py | 18 + src/contract_check/api/routes/metrics.py | 13 + src/contract_check/api/routes/reports.py | 42 + src/contract_check/api/services.py | 149 + src/contract_check/bot/__init__.py | 6 + src/contract_check/bot/__main__.py | 63 + src/contract_check/bot/client.py | 199 ++ src/contract_check/bot/config.py | 53 + src/contract_check/bot/handlers.py | 262 ++ src/contract_check/core/__init__.py | 20 + src/contract_check/core/analysis/__init__.py | 4 + src/contract_check/core/analysis/analyzer.py | 129 + src/contract_check/core/analysis/checklist.py | 108 + src/contract_check/core/analysis/chunker.py | 69 + src/contract_check/core/analysis/extractor.py | 80 + src/contract_check/core/analysis/ocr.py | 65 + .../core/analysis/report_schema.py | 62 + src/contract_check/core/api_keys.py | 26 + src/contract_check/core/auth.py | 267 ++ src/contract_check/core/config.py | 123 + src/contract_check/core/credits.py | 84 + src/contract_check/core/db/__init__.py | 5 + src/contract_check/core/db/enums.py | 67 + src/contract_check/core/db/models.py | 333 ++ src/contract_check/core/db/session.py | 43 + src/contract_check/core/errors.py | 17 + src/contract_check/core/llm/__init__.py | 12 + src/contract_check/core/llm/factory.py | 32 + src/contract_check/core/llm/ollama_cloud.py | 465 +++ src/contract_check/core/llm/port.py | 34 + src/contract_check/core/logging.py | 231 ++ src/contract_check/core/metrics.py | 86 + src/contract_check/core/mq/__init__.py | 6 + src/contract_check/core/mq/consumer.py | 282 ++ src/contract_check/core/mq/messages.py | 49 + src/contract_check/core/mq/publisher.py | 81 + src/contract_check/core/mq/topology.py | 206 ++ src/contract_check/core/rate_limit.py | 131 + src/contract_check/core/redis_client.py | 19 + src/contract_check/core/s3/__init__.py | 39 + src/contract_check/core/s3/minio_storage.py | 103 + src/contract_check/core/s3/port.py | 31 + src/contract_check/core/sentry.py | 31 + src/contract_check/core/telemetry.py | 73 + src/contract_check/core/tokens.py | 41 + src/contract_check/prototype/__init__.py | 158 + src/contract_check/prototype/__main__.py | 6 + src/contract_check/worker_analyze/__init__.py | 0 src/contract_check/worker_analyze/__main__.py | 62 + src/contract_check/worker_analyze/consumer.py | 67 + src/contract_check/worker_analyze/handler.py | 257 ++ src/contract_check/worker_extract/__init__.py | 0 src/contract_check/worker_extract/__main__.py | 62 + src/contract_check/worker_extract/consumer.py | 61 + .../worker_extract/extract_document.py | 26 + src/contract_check/worker_extract/handler.py | 240 ++ srv/api/Dockerfile | 38 + srv/bot/Dockerfile | 34 + srv/prototype/Dockerfile | 35 + srv/worker-analyze/Dockerfile | 35 + srv/worker-extract/Dockerfile | 42 + tests/conftest.py | 18 + tests/integration/conftest.py | 140 + tests/integration/test_analyze_worker.py | 265 ++ tests/integration/test_auth_flow.py | 168 ++ tests/integration/test_b2b_api.py | 202 ++ tests/integration/test_credits_db.py | 137 + tests/integration/test_extract_worker.py | 369 +++ tests/integration/test_upload_pipeline.py | 130 + tests/unit/test_auth.py | 198 ++ tests/unit/test_bot_boundary.py | 159 + tests/unit/test_bot_client.py | 277 ++ tests/unit/test_checklist_report.py | 23 + tests/unit/test_chunker.py | 23 + tests/unit/test_credits.py | 35 + tests/unit/test_extractor.py | 25 + tests/unit/test_llm_ollama_cloud.py | 226 ++ tests/unit/test_messages.py | 66 + tests/unit/test_rate_limit.py | 54 + uv.lock | 2667 +++++++++++++++++ 110 files changed, 15867 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Makefile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 docker-compose.yml create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BUSINESS_IDEA.md create mode 100644 docs/DEPLOY.md create mode 100644 docs/IMPLEMENTATION_PLAN.md create mode 100644 docs/TICKETS.md create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/0001_initial.py create mode 100644 migrations/versions/0002_api_keys.py create mode 100644 pyproject.toml create mode 100644 src/contract_check/__init__.py create mode 100644 src/contract_check/__main__.py create mode 100644 src/contract_check/api/__main__.py create mode 100644 src/contract_check/api/app.py create mode 100644 src/contract_check/api/deps.py create mode 100644 src/contract_check/api/middleware.py create mode 100644 src/contract_check/api/routes/README.md create mode 100644 src/contract_check/api/routes/__init__.py create mode 100644 src/contract_check/api/routes/auth.py create mode 100644 src/contract_check/api/routes/b2b.py create mode 100644 src/contract_check/api/routes/documents.py create mode 100644 src/contract_check/api/routes/health.py create mode 100644 src/contract_check/api/routes/me.py create mode 100644 src/contract_check/api/routes/metrics.py create mode 100644 src/contract_check/api/routes/reports.py create mode 100644 src/contract_check/api/services.py create mode 100644 src/contract_check/bot/__init__.py create mode 100644 src/contract_check/bot/__main__.py create mode 100644 src/contract_check/bot/client.py create mode 100644 src/contract_check/bot/config.py create mode 100644 src/contract_check/bot/handlers.py create mode 100644 src/contract_check/core/__init__.py create mode 100644 src/contract_check/core/analysis/__init__.py create mode 100644 src/contract_check/core/analysis/analyzer.py create mode 100644 src/contract_check/core/analysis/checklist.py create mode 100644 src/contract_check/core/analysis/chunker.py create mode 100644 src/contract_check/core/analysis/extractor.py create mode 100644 src/contract_check/core/analysis/ocr.py create mode 100644 src/contract_check/core/analysis/report_schema.py create mode 100644 src/contract_check/core/api_keys.py create mode 100644 src/contract_check/core/auth.py create mode 100644 src/contract_check/core/config.py create mode 100644 src/contract_check/core/credits.py create mode 100644 src/contract_check/core/db/__init__.py create mode 100644 src/contract_check/core/db/enums.py create mode 100644 src/contract_check/core/db/models.py create mode 100644 src/contract_check/core/db/session.py create mode 100644 src/contract_check/core/errors.py create mode 100644 src/contract_check/core/llm/__init__.py create mode 100644 src/contract_check/core/llm/factory.py create mode 100644 src/contract_check/core/llm/ollama_cloud.py create mode 100644 src/contract_check/core/llm/port.py create mode 100644 src/contract_check/core/logging.py create mode 100644 src/contract_check/core/metrics.py create mode 100644 src/contract_check/core/mq/__init__.py create mode 100644 src/contract_check/core/mq/consumer.py create mode 100644 src/contract_check/core/mq/messages.py create mode 100644 src/contract_check/core/mq/publisher.py create mode 100644 src/contract_check/core/mq/topology.py create mode 100644 src/contract_check/core/rate_limit.py create mode 100644 src/contract_check/core/redis_client.py create mode 100644 src/contract_check/core/s3/__init__.py create mode 100644 src/contract_check/core/s3/minio_storage.py create mode 100644 src/contract_check/core/s3/port.py create mode 100644 src/contract_check/core/sentry.py create mode 100644 src/contract_check/core/telemetry.py create mode 100644 src/contract_check/core/tokens.py create mode 100644 src/contract_check/prototype/__init__.py create mode 100644 src/contract_check/prototype/__main__.py create mode 100644 src/contract_check/worker_analyze/__init__.py create mode 100644 src/contract_check/worker_analyze/__main__.py create mode 100644 src/contract_check/worker_analyze/consumer.py create mode 100644 src/contract_check/worker_analyze/handler.py create mode 100644 src/contract_check/worker_extract/__init__.py create mode 100644 src/contract_check/worker_extract/__main__.py create mode 100644 src/contract_check/worker_extract/consumer.py create mode 100644 src/contract_check/worker_extract/extract_document.py create mode 100644 src/contract_check/worker_extract/handler.py create mode 100644 srv/api/Dockerfile create mode 100644 srv/bot/Dockerfile create mode 100644 srv/prototype/Dockerfile create mode 100644 srv/worker-analyze/Dockerfile create mode 100644 srv/worker-extract/Dockerfile create mode 100644 tests/conftest.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_analyze_worker.py create mode 100644 tests/integration/test_auth_flow.py create mode 100644 tests/integration/test_b2b_api.py create mode 100644 tests/integration/test_credits_db.py create mode 100644 tests/integration/test_extract_worker.py create mode 100644 tests/integration/test_upload_pipeline.py create mode 100644 tests/unit/test_auth.py create mode 100644 tests/unit/test_bot_boundary.py create mode 100644 tests/unit/test_bot_client.py create mode 100644 tests/unit/test_checklist_report.py create mode 100644 tests/unit/test_chunker.py create mode 100644 tests/unit/test_credits.py create mode 100644 tests/unit/test_extractor.py create mode 100644 tests/unit/test_llm_ollama_cloud.py create mode 100644 tests/unit/test_messages.py create mode 100644 tests/unit/test_rate_limit.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72e371d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.git +.github +.venv +venv +env +__pycache__ +**/__pycache__ +*.py[cod] +.pytest_cache +.mypy_cache +.ruff_cache +htmlcov +.coverage +.coverage.* +.env +data +reports +tmp +*.log +.gitignore +.dockerignore +srv +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..21c80ee --- /dev/null +++ b/.env.example @@ -0,0 +1,74 @@ +# ============================================================ +# «Контракт-чек» — production config (see docs/ARCHITECTURE.md §11) +# Copy to `.env` and fill in. All config is 12-factor (env-driven). +# ============================================================ + +# --- Runtime --- +ENV=dev # dev | staging | prod +LOG_LEVEL=INFO +LOG_FORMAT=json # json (prod/staging) | console (dev) +APP_VERSION=0.1.0 # added to every log line; set at build/deploy time + +# --- Postgres (async) --- +DATABASE_URL=postgresql+asyncpg://contract_check:contract_check@postgres:5432/contract_check + +# --- Redis (rate-limit / sessions, future; NOT the job queue) --- +REDIS_URL=redis://redis:6379/0 + +# --- RabbitMQ (job queue pipeline) --- +RABBITMQ_URL=amqp://contract_check:contract_check@rabbitmq:5672// +MQ_PREFETCH_EXTRACT=1 # CPU-bound extraction; tune to CPU count +MQ_PREFETCH_ANALYZE=3 # mirrors Ollama Pro concurrency +MQ_MAX_ATTEMPTS=5 # before a message lands on the DLQ +MQ_RETRY_BASE_MS=2000 # exponential backoff base (2s, 4s, 8s, ...) + +# --- MinIO (object storage) --- +S3_ENDPOINT_URL=http://minio:9000 +S3_ACCESS_KEY=contract_check +S3_SECRET_KEY=contract_check +S3_BUCKET=contract-check-docs +S3_REGION=us-east-1 +S3_SERVER_SIDE_ENCRYPTION=false # true in prod +DOC_RETENTION_DAYS=7 # MinIO ILM expiry for raw docs (152-ФЗ lever) +TEXT_RETENTION_DAYS=30 # expiry for extracted .txt blobs + +# --- Billing --- +REFUND_POLICY=all # all | infra_only (see docs/ARCHITECTURE.md §8) + +# --- Observability (leave empty to disable) --- +SENTRY_DSN= +OTEL_EXPORTER_OTLP_ENDPOINT= # e.g. http://otel-collector:4317 +OTEL_SERVICE_NAME=contract-check + +# --- LLM provider (abstract port; first realization = Ollama Cloud) --- +# For Ollama Cloud use https://ollama.com (not api.ollama.com). Models must be +# available on the chosen host — cloud models differ from local Ollama models. +LLM_PROVIDER=ollama_cloud +OLLAMA_HOST=https://ollama.com +OLLAMA_API_KEY=replace-me +OLLAMA_MODEL=qwen2.5:14b +OLLAMA_FALLBACK_MODEL=qwen2.5:7b +OLLAMA_TEMPERATURE=0.2 +OLLAMA_NUM_PREDICT=3072 +OLLAMA_TIMEOUT=120 +OLLAMA_MAX_CONCURRENCY=3 +CHUNK_SIZE_CHARS=10000 + +# --- API (FastAPI) --- +API_HOST=0.0.0.0 +API_PORT=8000 +API_METRICS_PORT=9100 +B2B_DEFAULT_RATE_LIMIT_RPS=3 # per API key; mirrors Ollama Pro concurrency +CORS_ORIGINS= # comma-separated, future web SPA + +# --- Auth (JWT + Telegram identity verification) --- +# Telegram bot token is also used by the API to verify Login Widget / Mini App signatures. +TELEGRAM_BOT_TOKEN= +JWT_SECRET= # HS256 secret for signing user JWTs; generate with `openssl rand -hex 32` +JWT_ALGORITHM=HS256 +JWT_ACCESS_TTL_MINUTES=1440 # 24 hours default + +# --- Telegram bot (adapter, HTTP-only to api) --- +BOT_TOKEN= # same value as TELEGRAM_BOT_TOKEN (kept for the bot image) +BOT_SERVICE_TOKEN= # bearer looked up against service_tokens.name="bot-prod" +API_URL=http://api:8000 # base URL of the api service (container DNS in compose) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ce94e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +env/ +.env +*.egg-info/ +.eggs/ +build/ +dist/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage +.coverage.* + +# Notebooks +.ipynb_checkpoints/ + +# OS / editor +.DS_Store +.idea/ +.vscode/ +*.swp + +# Project +*.log +reports/ +tmp/ +data/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cd0dc1b --- /dev/null +++ b/Makefile @@ -0,0 +1,162 @@ +# «Контракт-чек» — everyday commands (uv + docker) +# Usage: make (see `make help`) + +.PHONY: help install lint typecheck test test-unit test-integration migrate \ + infra-up infra-down infra-logs services-up services-down services-logs \ + api api-logs bot bot-logs worker-extract worker-analyze seed-token \ + jwt-secret jwt-token jwt-verify health shell-api shell-bot shell-db clean + +# ───────────────────────────────────────────────────────────────────────────── +# Help +# ───────────────────────────────────────────────────────────────────────────── +help: ## Show available commands + @echo "Usage: make " + @grep -E '^[a-zA-Z0-9_-]+:.*##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*##"}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' + +# ───────────────────────────────────────────────────────────────────────────── +# Local dev (uv) +# ───────────────────────────────────────────────────────────────────────────── +install: ## Sync dev dependencies (uv) + uv sync --group dev + +lint: ## Run ruff linter + import sorter + uv run ruff check src tests + uv run ruff format --check src tests + +typecheck: ## Run mypy type checker + uv run mypy src + +test: ## Run all tests (unit + integration) + uv run pytest + +test-unit: ## Run unit tests only (fast) + uv run pytest -m "not integration" + +test-integration: ## Run integration tests (needs docker infra) + uv run pytest -m integration + +# ───────────────────────────────────────────────────────────────────────────── +# Docker: infrastructure (postgres + redis + rabbitmq + minio) +# ───────────────────────────────────────────────────────────────────────────── +infra-up: ## Start infra containers + docker compose up -d + +infra-down: ## Stop infra containers + docker compose down + +infra-logs: ## Tail infra logs + docker compose logs -f + +# ───────────────────────────────────────────────────────────────────────────── +# Docker: all services (api + workers + bot) +# ───────────────────────────────────────────────────────────────────────────── +services-up: ## Start all services (needs infra running) + docker compose --profile services up -d --build --remove-orphans + +services-down: ## Stop all services + docker compose --profile services down + +services-logs: ## Tail all service logs + docker compose --profile services logs -f + +services-ps: ## Show running containers + docker compose --profile services ps + +# ───────────────────────────────────────────────────────────────────────────── +# Individual services +# ───────────────────────────────────────────────────────────────────────────── +api: ## Start/restart API service + docker compose --profile services up -d --build --remove-orphans api + +api-logs: ## Tail API logs + docker compose logs -f api + +bot: ## Start/restart Telegram bot + docker compose --profile services up -d --build --remove-orphans bot + +bot-logs: ## Tail bot logs + docker compose logs -f bot + +worker-extract: ## Start/restart extract worker + docker compose --profile services up -d --build --remove-orphans worker-extract + +worker-analyze: ## Start/restart analyze worker + docker compose --profile services up -d --build --remove-orphans worker-analyze + +worker-analyze-logs: ## Tail analyze worker logs + docker compose logs -f worker-analyze + +# ───────────────────────────────────────────────────────────────────────────── +# Database +# ───────────────────────────────────────────────────────────────────────────── +migrate: ## Run Alembic migrations (inside api container) + docker compose --profile services build api + docker compose --profile services run --rm api alembic upgrade head + +shell-db: ## Open psql inside postgres container + docker compose exec postgres psql -U contract_check -d contract_check + +# ───────────────────────────────────────────────────────────────────────────── +# Auth / tokens +# ───────────────────────────────────────────────────────────────────────────── +seed-token: ## Generate bot service token (prints bearer token) + docker compose --profile services exec api python -m contract_check.api seed-token bot-prod bot + +jwt-secret: ## Generate a fresh JWT_SECRET for .env + @openssl rand -hex 32 + +jwt-token: ## Exchange a telegram_id for a user JWT (usage: make jwt-token TG_ID=123456) + @if [ -z "$(TG_ID)" ]; then \ + echo "Usage: make jwt-token TG_ID=123456"; \ + exit 1; \ + fi + @TOKEN=$$(grep -E '^BOT_SERVICE_TOKEN=' .env | cut -d= -f2); \ + if [ -z "$$TOKEN" ]; then \ + echo "BOT_SERVICE_TOKEN not found in .env"; \ + exit 1; \ + fi; \ + curl -s -X POST -H "Authorization: Bearer $$TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"telegram_id":$(TG_ID)}' \ + http://localhost:8000/api/v1/auth/telegram/bot | jq . + +jwt-verify: ## Introspect a user JWT (usage: make jwt-verify JWT=eyJ...) + @if [ -z "$(JWT)" ]; then \ + echo "Usage: make jwt-verify JWT=eyJ..."; \ + exit 1; \ + fi + @curl -s -H "Authorization: Bearer $(JWT)" \ + http://localhost:8000/api/v1/auth/me | jq . + +# ───────────────────────────────────────────────────────────────────────────── +# Health / diagnostics +# ───────────────────────────────────────────────────────────────────────────── +health: ## Check API health endpoint + @echo "API health:" + @curl -s http://localhost:8000/healthz | jq . 2>/dev/null || curl -s http://localhost:8000/healthz + @echo "" + @echo "RabbitMQ: http://localhost:15672 (guest/guest → contract_check/contract_check)" + @echo "MinIO: http://localhost:9001 (contract_check/contract_check)" + @echo "Postgres: localhost:15432 (contract_check/contract_check)" + +shell-api: ## Open shell inside API container + docker compose --profile services exec api /bin/sh + +shell-bot: ## Open shell inside bot container + docker compose --profile services exec bot /bin/sh + +# ───────────────────────────────────────────────────────────────────────────── +# Cleanup +# ───────────────────────────────────────────────────────────────────────────── +clean: ## Remove containers, volumes, caches + docker compose --profile services down -v + docker compose down -v + rm -rf .mypy_cache .pytest_cache .ruff_cache + uv cache clean + +# ───────────────────────────────────────────────────────────────────────────── +# Full workflow shortcuts +# ───────────────────────────────────────────────────────────────────────────── +dev: install infra-up migrate services-up ## Bootstrap full dev environment + +stop: services-down infra-down ## Stop everything diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b59155 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# Контракт-чек + +LLM-сервис скрининга рисков в договорах (PDF/DOCX) по ГК РФ / ГК РБ. +Telegram-бот MVP + B2B API сейчас; веб + подписки — позже. + +Pipeline: `PDF/DOCX → текст (pymupdf/tesseract OCR) → чанки → Ollama Cloud (LLM, json-schema + repair-loop) → markdown-отчёт` с цитатами, ссылкой на пункт и дисклеймером «не заменяет юриста». + +## Документы + +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — **единственный источник истины** для production-рефакторинга. Hexagonal-архитектура, RabbitMQ-конвейер, схемы БД, очередь/ретраи/DLQ, конфиг, deploy, observability. +- [`docs/DEPLOY.md`](docs/DEPLOY.md) — руководство по развёртыванию: локально, Docker Compose, VPS, seed-token, backup, troubleshooting. +- [`docs/BUSINESS_IDEA.md`](docs/BUSINESS_IDEA.md) — идея и бизнес-модель. +- [`docs/IMPLEMENTATION_PLAN.md`](docs/IMPLEMENTATION_PLAN.md) — исходный «ленивый» план. Этап 0 (прототип) актуален; этапы 1+ superseded в `docs/ARCHITECTURE.md`. +- [`docs/TICKETS.md`](docs/TICKETS.md) — тикеты реализации со статусами. + +## Архитектура (одна строка) + +Hexagonal (ports & adapters). Ядро `contract_check.core` владеет всем состоянием +(Postgres/MinIO/RabbitMQ/LLM/кредиты). Четыре сервиса собираются из него: + +``` +Telegram ──► bot (aiogram, HTTP-only) ──HTTP──► api (FastAPI) ──publish──► RabbitMQ + владеет: PG, MinIO, │ + Redis, кредитами ▼ + extract.q ──► worker-extract + (pymupdf+tesseract, CPU) │ + ▼ publish + analyze.q ──► worker-analyze + (LLM, I/O) → Report +``` + +- **api** — единственный писатель для пользовательских мутаций (upload → reserve credit → MinIO → publish `DocumentUploaded`). +- **worker-extract** — CPU: достаёт текст (PDF/DOCX), при необходимости OCR, грузит `.txt` в MinIO, публикует `DocumentExtracted`. +- **worker-analyze** — I/O: LLM-анализ по чек-листу, валидация/repair, сохраняет `Report`, `status=done`. +- **bot** — адаптер: HTTP-клиент к api, **не импортирует** core.db/s3/llm/mq (граница проверяется тестом `tests/unit/test_bot_boundary.py`). + +## Структура репозитория + +``` +src/contract_check/ + __main__.py # указывает на prototype (stage-0 CLI сохранён) + core/ # общий домен (импортируется каждым сервисом) + config.py logging.py telemetry.py sentry.py metrics.py + credits.py tokens.py api_keys.py rate_limit.py redis_client.py + db/ models.py session.py enums.py + mq/ topology.py publisher.py consumer.py messages.py + s3/ port.py minio_storage.py + llm/ port.py ollama_cloud.py factory.py + analysis/ extractor.py chunker.py checklist.py report_schema.py ocr.py analyzer.py + api/ # FastAPI-образ + app.py deps.py middleware.py services.py __main__.py + routes/ health.py documents.py reports.py me.py metrics.py b2b.py + worker_extract/ # CPU-образ (pymupdf + tesseract) + consumer.py handler.py extract_document.py __main__.py + worker_analyze/ # I/O-образ (LLM provider) + consumer.py handler.py __main__.py + bot/ # aiogram-адаптер (самый «тощий» образ: только core.logging) + client.py config.py handlers.py __main__.py + prototype/ # stage-0 standalone CLI (бенчмарк go/no-go) +docs/ # документация проекта (README остаётся в корне) + ARCHITECTURE.md # архитектура, схемы БД, конфиг + DEPLOY.md # руководство по развёртыванию + TICKETS.md # статусы тикетов + IMPLEMENTATION_PLAN.md # исходный план (superseded для этапов 1+) + BUSINESS_IDEA.md # продукт / бизнес-модель +srv/ # Dockerfile-ы (один на сервис, deps заточены) + api/Dockerfile worker-extract/Dockerfile worker-analyze/Dockerfile + bot/Dockerfile prototype/Dockerfile +migrations/ # alembic (async): 0001_initial, 0002_api_keys +tests/ + conftest.py + unit/ chunker, extractor, llm_ollama_cloud, credits, messages, + rate_limit, checklist_report, bot_client, bot_boundary + integration/ upload_pipeline, extract_worker, analyze_worker, b2b_api, credits_db +docker-compose.yml # default = инфра; --profile services = стек +pyproject.toml # hatchling + PEP 735 dependency-groups (db/mq/s3/obs/api/extract/analyze/bot/prototype/dev) +.env.example # полный список env (см. docs/ARCHITECTURE.md §11) +``` + +## Быстрый старт + +### Локально (разработка) + +```bash +uv sync --group dev # все группы для локальной разработки +cp .env.example .env # впишите OLLAMA_HOST / OLLAMA_API_KEY +docker compose up -d # только инфра (postgres/redis/rabbitmq/minio) +uv run alembic upgrade head # миграции +uv run python -m contract_check.api # api на :8000 +uv run python -m contract_check.worker_extract # воркер экстракции +uv run python -m contract_check.worker_analyze # воркер анализа +uv run python -m contract_check.bot # Telegram-бот +``` + +### Через Docker Compose + +```bash +cp .env.example .env # заполнить секреты (DB, Rabbit, MinIO, Ollama, BOT_TOKEN, ...) +docker compose up -d # только инфра с healthchecks +docker compose --profile services up -d --build # + api, worker-extract, worker-analyze, bot +``` + +Профиль `services` собирает 4 образа из `srv//Dockerfile` и поднимает их +с `depends_on: condition: service_healthy`. Observability (Prometheus/Grafana/Tempo/OTel) +и edge (Nginx/certbot) — за будущими профилями `obs`/`edge` (`docs/ARCHITECTURE.md §20`, шаги 1–5 +реализованы; шаг 6 — позже). + +Порты на хосте (смещены, чтобы не конфликтовать): Postgres `15432`, Redis `17379`, +RabbitMQ AMQP `5672` / UI `15672`, MinIO `9000` / console `9001`, api `8000` / metrics `9100`, +worker metrics `9101`/`9102`. + +### Stage-0 прототип (бенчмарк) + +```bash +uv sync --group prototype +uv run python -m contract_check prototype contract.pdf # отчёт в stdout +uv run contract-check contract.pdf -o report.md # или консольная команда +uv run python -m contract_check prototype contract.pdf --json metrics.json # + метрики go/no-go +``` + +## API (кратко) + +Все роуты под `/api/v1`. Auth зависит от роута: + +- **Пользовательские роуты** (bot / web / Mini App) — `Authorization: Bearer `. JWT выдаётся через `/api/v1/auth/telegram/*` после проверки identity от Telegram. +- **Адаптер-level** (только `/api/v1/auth/telegram/bot`) — `Authorization: Bearer `. +- **B2B** — `X-API-Key`. Управление B2B-ключами требует пользовательский JWT. +- **Health/metrics** — без auth. + +| Метод | Путь | Auth | Назначение | +|---|---|---|---| +| GET | `/healthz`, `/readyz`, `/metrics` | — | liveness / readiness / Prometheus | +| POST | `/api/v1/auth/telegram/bot` | service token | бот меняет verified `telegram_id` на JWT | +| POST | `/api/v1/auth/telegram/web` | — | Telegram Login Widget → JWT | +| POST | `/api/v1/auth/telegram/miniapp` | — | Mini App `initData` → JWT | +| GET | `/api/v1/auth/me` | user JWT | introspect JWT | +| POST | `/api/v1/documents` | user JWT | multipart upload → reserve credit → MinIO → publish → `202 {document_id, correlation_id}` | +| GET | `/api/v1/documents/{id}` | user JWT | статус + stage (для поллинга) | +| GET | `/api/v1/reports/{document_id}` | user JWT | `202 {status, stage}` или `200 {markdown, findings, ...}` | +| GET | `/api/v1/me` | user JWT | `{telegram_id, credits_left}` | +| POST | `/api/v1/analyze` | `X-API-Key` | B2B: анализ документа | +| GET | `/api/v1/b2b/reports/{id}`, `/api/v1/b2b/usage` | `X-API-Key` | B2B: отчёт / usage | +| POST/GET | `/api/v1/b2b/keys`, `/api/v1/b2b/keys/{id}/revoke`, `.../usage` | user JWT | управление B2B-ключами | + +Полная спецификация — `docs/ARCHITECTURE.md §15` (актуализируется). + +## Проверки (DoD) + +```bash +uv run ruff check . && uv run mypy src && uv run pytest -q # unit, быстро +uv run pytest -m integration -q # интеграционные (нужны контейнеры) +``` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..5cbcc5d --- /dev/null +++ b/alembic.ini @@ -0,0 +1,40 @@ +# Alembic config. The DB URL is resolved at runtime from DATABASE_URL env +# (see migrations/env.py), so the [alembic] sqlalchemy.url here is a fallback. +[alembic] +script_location = migrations +prepend_sys_path = src +sqlalchemy.url = postgresql+asyncpg://contract_check:contract_check@localhost:5432/contract_check + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4f9386f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,299 @@ +# «Контракт-чек» — infrastructure (Step 1). +# +# Default (`docker compose up`) starts ONLY infra: postgres + redis + rabbitmq +# + minio (+ minio-init). Service containers (api, worker-extract, worker-analyze, +# bot) and the observability/edge stacks are added behind profiles in later steps +# (docs/ARCHITECTURE.md §20). +# +# Durability posture (§10): quorum-ready. Postgres is configured +# wal_level=replica + WAL archiving (replica/PITR-ready). RabbitMQ quorum queues +# are declared by the app (core/mq/topology.py) — they replicate the moment a +# 3-node cluster is added. Named volumes everywhere; restart: unless-stopped. + +services: + postgres: + image: postgres:18-alpine + container_name: contract_check-postgres + restart: unless-stopped + command: + - "postgres" + - "-c" + - "wal_level=replica" + - "-c" + - "archive_mode=on" + - "-c" + - "archive_command=test ! -f /walarchive/%f && cp %p /walarchive/%f" + environment: + POSTGRES_USER: ${POSTGRES_USER:-contract_check} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-contract_check} + POSTGRES_DB: ${POSTGRES_DB:-contract_check} + volumes: + - pgdata:/var/lib/postgresql + - pgwal:/walarchive + ports: + - "15432:5432" + healthcheck: + test: + - CMD-SHELL + - "pg_isready -U ${POSTGRES_USER:-contract_check} -d ${POSTGRES_DB:-contract_check}" + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:8-alpine + container_name: contract_check-redis + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redisdata:/data + ports: + - "17379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + rabbitmq: + image: rabbitmq:4-management-alpine + container_name: contract_check-rabbitmq + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-contract_check} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-contract_check} + RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-/} + volumes: + - rabbitmq:/var/lib/rabbitmq + ports: + - "5672:5672" # AMQP + - "15672:15672" # management UI (http://localhost:15672) + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 15s + + minio: + image: minio/minio:latest + container_name: contract_check-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY:-contract_check} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-contract_check} + volumes: + - minio:/data + ports: + - "9000:9000" # S3 API + - "9001:9001" # console (http://localhost:9001) + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:9000/minio/health/ready"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + + # One-shot: create the bucket + an ILM expiry rule (152-ФЗ retention lever). + # Service containers (Step 2+) gate on `service_completed_successfully`. + minio-init: + image: minio/mc:latest + container_name: contract_check-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: /bin/sh + command: + - -c + - | + set -e + mc alias set local http://minio:9000 "$${MINIO_ROOT_USER:-contract_check}" "$${MINIO_ROOT_PASSWORD:-contract_check}" + mc mb --ignore-existing local/${S3_BUCKET:-contract-check-docs} + mc anonymous set none local/${S3_BUCKET:-contract-check-docs} || true + # Expire raw docs + extracted text after DOC_RETENTION_DAYS (default 7). + mc ilm rule add --expire-days ${DOC_RETENTION_DAYS:-7} local/${S3_BUCKET:-contract-check-docs} || true + echo "bucket ${S3_BUCKET:-contract-check-docs} ready (ilm expire ${DOC_RETENTION_DAYS:-7}d)" + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY:-contract_check} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-contract_check} + restart: "no" + + # ── SERVICES (profile: services) ──────────────────────────────────────────── + # Core FastAPI service. Runs migrations separately (see deploy docs); assumes + # the DB is migrated before accepting traffic via healthcheck delay. + api: + profiles: ["services"] + build: + context: . + dockerfile: srv/api/Dockerfile + container_name: contract_check-api + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + minio-init: + condition: service_completed_successfully + environment: + ENV: ${ENV:-dev} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} + REDIS_URL: redis://redis:6379/0 + RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} + S3_ENDPOINT_URL: http://minio:9000 + S3_ACCESS_KEY: ${S3_ACCESS_KEY:-contract_check} + S3_SECRET_KEY: ${S3_SECRET_KEY:-contract_check} + S3_BUCKET: ${S3_BUCKET:-contract-check-docs} + S3_REGION: ${S3_REGION:-us-east-1} + S3_SERVER_SIDE_ENCRYPTION: ${S3_SERVER_SIDE_ENCRYPTION:-false} + DOC_RETENTION_DAYS: ${DOC_RETENTION_DAYS:-7} + REFUND_POLICY: ${REFUND_POLICY:-all} + SENTRY_DSN: ${SENTRY_DSN:-} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_SERVICE_NAME: api + LLM_PROVIDER: ${LLM_PROVIDER:-ollama_cloud} + OLLAMA_HOST: ${OLLAMA_HOST:-} + OLLAMA_API_KEY: ${OLLAMA_API_KEY:-} + OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen2.5:14b} + OLLAMA_FALLBACK_MODEL: ${OLLAMA_FALLBACK_MODEL:-qwen2.5:7b} + OLLAMA_TEMPERATURE: ${OLLAMA_TEMPERATURE:-0.2} + OLLAMA_NUM_PREDICT: ${OLLAMA_NUM_PREDICT:-3072} + OLLAMA_TIMEOUT: ${OLLAMA_TIMEOUT:-120} + OLLAMA_MAX_CONCURRENCY: ${OLLAMA_MAX_CONCURRENCY:-3} + CHUNK_SIZE_CHARS: ${CHUNK_SIZE_CHARS:-10000} + API_HOST: ${API_HOST:-0.0.0.0} + API_PORT: ${API_PORT:-8000} + API_METRICS_PORT: ${API_METRICS_PORT:-9100} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + JWT_SECRET: ${JWT_SECRET} + JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256} + JWT_ACCESS_TTL_MINUTES: ${JWT_ACCESS_TTL_MINUTES:-1440} + ports: + - "${API_PORT:-8000}:8000" + - "${API_METRICS_PORT:-9100}:9100" + healthcheck: + test: + - CMD-SHELL + - "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')\"" + interval: 10s + timeout: 3s + retries: 10 + start_period: 15s + + worker-extract: + profiles: ["services"] + build: + context: . + dockerfile: srv/worker-extract/Dockerfile + container_name: contract_check-worker-extract + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + minio-init: + condition: service_completed_successfully + environment: + ENV: ${ENV:-dev} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} + RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} + S3_ENDPOINT_URL: http://minio:9000 + S3_ACCESS_KEY: ${S3_ACCESS_KEY:-contract_check} + S3_SECRET_KEY: ${S3_SECRET_KEY:-contract_check} + S3_BUCKET: ${S3_BUCKET:-contract-check-docs} + S3_REGION: ${S3_REGION:-us-east-1} + S3_SERVER_SIDE_ENCRYPTION: ${S3_SERVER_SIDE_ENCRYPTION:-false} + DOC_RETENTION_DAYS: ${DOC_RETENTION_DAYS:-7} + REFUND_POLICY: ${REFUND_POLICY:-all} + SENTRY_DSN: ${SENTRY_DSN:-} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_SERVICE_NAME: worker-extract + MQ_PREFETCH_EXTRACT: ${MQ_PREFETCH_EXTRACT:-1} + MQ_MAX_ATTEMPTS: ${MQ_MAX_ATTEMPTS:-5} + MQ_RETRY_BASE_MS: ${MQ_RETRY_BASE_MS:-2000} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + JWT_SECRET: ${JWT_SECRET} + JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256} + JWT_ACCESS_TTL_MINUTES: ${JWT_ACCESS_TTL_MINUTES:-1440} + ports: + - "9101:9101" + + worker-analyze: + profiles: ["services"] + build: + context: . + dockerfile: srv/worker-analyze/Dockerfile + container_name: contract_check-worker-analyze + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + minio-init: + condition: service_completed_successfully + environment: + ENV: ${ENV:-dev} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} + RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} + S3_ENDPOINT_URL: http://minio:9000 + S3_ACCESS_KEY: ${S3_ACCESS_KEY:-contract_check} + S3_SECRET_KEY: ${S3_SECRET_KEY:-contract_check} + S3_BUCKET: ${S3_BUCKET:-contract-check-docs} + S3_REGION: ${S3_REGION:-us-east-1} + REFUND_POLICY: ${REFUND_POLICY:-all} + SENTRY_DSN: ${SENTRY_DSN:-} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_SERVICE_NAME: worker-analyze + MQ_PREFETCH_ANALYZE: ${MQ_PREFETCH_ANALYZE:-3} + MQ_MAX_ATTEMPTS: ${MQ_MAX_ATTEMPTS:-5} + MQ_RETRY_BASE_MS: ${MQ_RETRY_BASE_MS:-2000} + LLM_PROVIDER: ${LLM_PROVIDER:-ollama_cloud} + OLLAMA_HOST: ${OLLAMA_HOST:-} + OLLAMA_API_KEY: ${OLLAMA_API_KEY:-} + OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen2.5:14b} + OLLAMA_FALLBACK_MODEL: ${OLLAMA_FALLBACK_MODEL:-qwen2.5:7b} + OLLAMA_TEMPERATURE: ${OLLAMA_TEMPERATURE:-0.2} + OLLAMA_NUM_PREDICT: ${OLLAMA_NUM_PREDICT:-3072} + OLLAMA_TIMEOUT: ${OLLAMA_TIMEOUT:-120} + OLLAMA_MAX_CONCURRENCY: ${OLLAMA_MAX_CONCURRENCY:-3} + CHUNK_SIZE_CHARS: ${CHUNK_SIZE_CHARS:-10000} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + JWT_SECRET: ${JWT_SECRET} + JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256} + JWT_ACCESS_TTL_MINUTES: ${JWT_ACCESS_TTL_MINUTES:-1440} + ports: + - "9102:9102" + + # Telegram bot adapter (aiogram 3, HTTP-only to api). Per docs/ARCHITECTURE.md §17 + # the bot holds no DB/MQ/S3 credentials — it depends on `api` being healthy, + # not on the infra containers directly, enforcing the hexagonal boundary even + # in dependency ordering. + bot: + profiles: ["services"] + build: + context: . + dockerfile: srv/bot/Dockerfile + container_name: contract_check-bot + restart: unless-stopped + depends_on: + api: + condition: service_healthy + environment: + ENV: ${ENV:-dev} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + BOT_TOKEN: ${BOT_TOKEN:-} + API_URL: ${API_URL:-http://api:8000} + BOT_SERVICE_TOKEN: ${BOT_SERVICE_TOKEN:-} + +volumes: + pgdata: + pgwal: + redisdata: + rabbitmq: + minio: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8ad6e4e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,1288 @@ +# ARCHITECTURE — «Контракт-чек» production refactor + +> **Audience:** a fresh agent (future me) picking this repo up cold. This doc +> is the single source of truth for the refactor. It supersedes the stage-1 +> architecture in `IMPLEMENTATION_PLAN.md` and the relevant tickets in +> `TICKETS.md` (T-E1-008/011/012/015 and anything mentioning `arq`, Selectel S3, +> or the `MODE=...` dispatcher). The stage-0 prototype and its tickets +> (T-E0-*) remain valid. `BUSINESS_IDEA.md` (product/business) is unchanged. +> +> **One-line summary:** take the stage-0 prototype (PDF/DOCX → text → Ollama +> Cloud → markdown report) and rebuild it as a production, event-driven, +> multi-container application: a FastAPI **api**, two RabbitMQ-consuming +> **workers** (extract/OCR and LLM-analyze, split for CPU vs I/O profiles), +> a Telegram **bot** adapter, backed by **Postgres + RabbitMQ + MinIO + Redis**, +> observable via **structlog + Sentry + Prometheus/Grafana + OpenTelemetry**, +> behind **Nginx + certbot**. Monorepo, five fine-tuned Docker images +> (`srv/` — api + two workers + bot + prototype), shared `contract_check.core` domain package. Compose now, k8s-ready later. +> HA-*ready* (quorum queues, WAL-archiving Postgres), not HA-*running* on one +> VPS — see §10. + +--- + +## 1. Why this refactor (the pivot) + +The prototype proved the LLM path end-to-end (T-E0-005). The original plan +(`IMPLEMENTATION_PLAN.md`) carried that into production with three assumptions: + +1. **arq + Redis** as the job queue. +2. **Selectel S3** (cloud, RF-located) as object storage. +3. **One shared Docker image** with a `MODE=api|worker|bot` runtime dispatcher. + +The owner has explicitly pivoted away from all three: + +1. Queue → **RabbitMQ**, and the worker becomes **event-driven** (pipeline + fan-out: extract stage, then analyze stage), not a single in-process job. +2. Storage → **MinIO** (local/self-hosted S3), not Selectel. +3. Images → **one Dockerfile per service**, deps fine-tuned per image; the + `MODE` dispatcher is killed (one image = one entrypoint). + +Additional decisions locked during the architecture questionnaire: + +- **Two worker pools**, not one: `worker-extract` (pymupdf + Tesseract, CPU) + and `worker-analyze` (LLM, I/O). This pairs naturally with the pipeline + topology and lets each pool scale independently. +- **LLM behind a provider interface** (`core/llm/port.py`), Ollama Cloud as + the first realization — swappable later for self-hosted Ollama / GigaChat. +- **Refund policy is a runtime switch** (`REFUND_POLICY=all|infra_only`) with + a failure-class taxonomy; credit refund stays idempotent. +- **Observability is full**, not logs-only: structlog+correlation_id, Sentry, + Prometheus+Grafana, OpenTelemetry. +- **Payments (ЮKassa) deferred** to a follow-up; the `invoices` table ships + empty in the initial migration for forward compatibility. +- **Web SPA deferred**; api/worker/bot land first. +- **Landing is incremental**, verified green at each step. + +Non-goals for this refactor (do NOT build these now): multi-tenant orgs, +ЮKassa integration, React SPA, K8s manifests, RAG/vector DB, template +generation, E-sign/Gosuslugi. + +--- + +## 2. System overview + +Hexagonal (ports & adapters). The **core** owns all state and side effects +(DB, S3, MQ, LLM, credits). **Adapters** (bot, future web/cli) are thin +HTTP clients to the api and touch nothing but the api. + +``` + ┌───────────────────── HTTPS (Nginx + certbot) ─────────────────────┐ + │ │ + Telegram ───► bot (aiogram) ──HTTP──► api (FastAPI) ──publish──► RabbitMQ (direct) + (adapter, HTTP-only) (core) │ + │ owns: Postgres, MinIO, ▼ + │ Redis, credits, tokens extract.q ──► worker-extract + │ (core) (quorum) (core, CPU: pymupdf+tesseract) + ▼ │ publish document.extracted + Postgres ▼ + MinIO ◄──── read/write ────► analyze.q ──► worker-analyze + Redis (rate limit/sess) (quorum) (core, I/O: LLM provider) + │ save Report, status=done + ▼ + Postgres +``` + +Data plane: + +- **Postgres** — source of truth (users, documents, reports, jobs, tokens, + invoices). Replication-ready (§10). +- **MinIO** — raw doc blobs + extracted text blobs, TTL-purged. +- **RabbitMQ** — durable job pipeline, quorum queues, TTL retry, DLQ. +- **Redis** — rate limiting (future), sessions (future). Idle for now but in + compose; do not use it as a job queue. + +Control plane: + +- **api** is the only writer to Postgres/MinIO for user-initiated mutations. + Workers write their own stage rows and reports but never create users or + move credits except via the idempotent `refund_credit` helper. + +--- + +## 3. Locked decisions (cheat sheet) + +| Area | Decision | Replaces | +|---|---|---| +| Repo | Monorepo, shared `contract_check.core`, 5 Dockerfiles in `srv//` | one-image `MODE` dispatch | +| Scope now | api + worker-extract + worker-analyze + bot (+ prototype benchmark) | (web/payments later) | +| Queue | RabbitMQ, direct exchange, pipeline fan-out | arq + Redis | +| Retry | TTL retry queues, exponential backoff, final DLQ | — | +| Workers | Two pools (extract/OCR + analyze/LLM) | single arq worker | +| Storage | MinIO, `users/{uid}/docs/{did}.{ext}` | Selectel S3 | +| Upload | Proxy through API (multipart) | — | +| Doc retention | TTL purge of raw docs after N days | — | +| Redis | Kept (rate limit/sessions future) | (no longer the queue) | +| Auth | Per-adapter `service_tokens`, revocable | single `SERVICE_TOKEN` | +| Report delivery | Polling now (fine-grained stage), SSE/webhook later | — | +| Sync `/analyze` | No | — | +| Doc status | Fine-grained `queued→extracting→ocr→analyzing→done\|failed` | coarse status | +| Extra tables | jobs, service_tokens, invoices(stub) | 3-table plan | +| Report storage | JSONB + markdown column | — | +| Durability | HA-*ready* (quorum queues, WAL archive) | — | +| Observability | structlog+corr, Sentry, Prom/Grafana, OTel | docker logs | +| LLM | Provider port + Ollama Cloud adapter | direct client | +| Refund | Policy switch `all\|infra_only` + failure classes | refund-all | +| Edge | Nginx + certbot | — | +| Deploy | Compose now, k8s-ready later | — | +| Prototype | Kept as standalone benchmark | — | +| Tests | pytest+respx unit + testcontainers integration | — | +| Python | **3.13** (was 3.14) — wheel availability | py3.14 | +| Landing | Incremental, green per step | — | + +--- + +## 4. Repository layout (target) + +Existing modules migrate as annotated. New code is marked `(new)`. + +``` +DealDocumentScreening/ +├── ARCHITECTURE.md (this file) +├── BUSINESS_IDEA.md (unchanged — product/business) +├── IMPLEMENTATION_PLAN.md (stage-0 valid; stage-1+ superseded here) +├── TICKETS.md (T-E0-* valid; T-E1-* superseded here) +├── README.md (rewrite: how to run the stack) +├── pyproject.toml (rewrite: core + per-service extras + dev group) +├── uv.lock (regenerated) +├── .env.example (rewrite: full env list, §11) +├── docker-compose.yml (rewrite: infra + services, profiles, §12) +├── srv/ (new — one Dockerfile per service, deps-tuned) +│ ├── api/Dockerfile (new) +│ ├── worker-extract/Dockerfile (new, tesseract layer) +│ ├── worker-analyze/Dockerfile (new, lean) +│ ├── bot/Dockerfile (new) +│ └── prototype/Dockerfile (new, preserves stage-0 CLI) +├── deploy/ (PLANNED — not yet built; lands with §20 steps 6 / E1-009..010) +│ ├── nginx/ +│ │ ├── nginx.conf (planned — reverse proxy + TLS placeholders) +│ │ └── certbot-init.sh (planned) +│ └── observability/ +│ ├── prometheus.yml (planned — scrape api + workers :9100/:9101/:9102) +│ ├── otel-collector-config.yaml (planned) +│ ├── tempo.yaml (planned — trace storage) +│ └── grafana/provisioning/ +│ ├── datasources/ (prometheus + tempo) +│ └── dashboards/ (starter: queue depth, job latency, LLM tokens) +├── migrations/ (new — alembic) +│ ├── env.py +│ ├── script.py.mako +│ └── versions/ +│ ├── 0001_initial.py (6 tables, §7) +│ └── 0002_api_keys.py (api_keys, api_key_requests — B2B, §15) +├── src/contract_check/ +│ ├── __init__.py +│ ├── core/ (new — shared domain, imported by every service image) +│ │ ├── __init__.py +│ │ ├── config.py (pydantic-settings: base + per-service, §11) +│ │ ├── logging.py (structlog JSON + correlation_id contextvar) +│ │ ├── telemetry.py (OTel SDK init, FastAPI/asyncio instrumentation) +│ │ ├── sentry.py (sentry_sdk init helper) +│ │ ├── metrics.py (prometheus_client registry + counters/hists) +│ │ ├── api_keys.py (B2B key gen/hash/verify — sha256 + hmac.compare_digest) +│ │ ├── rate_limit.py (token-bucket per api_key_id; Memory + Redis backends) +│ │ ├── redis_client.py (async Redis client from redis_url) +│ │ ├── db/ +│ │ │ ├── __init__.py +│ │ │ ├── models.py (SQLAlchemy 2 decl: User, Document, Report, Job, ServiceToken, Invoice, ApiKey, ApiKeyRequest) +│ │ │ ├── session.py (async_sessionmaker, engine) +│ │ │ └── enums.py (DocStatus, JobStatus, FailureClass — as plain str constants) +│ │ ├── mq/ +│ │ │ ├── __init__.py +│ │ │ ├── topology.py (exchange/queue/rk constants + declare_all()) +│ │ │ ├── publisher.py (aio-pika RobustChannel, publisher confirms) +│ │ │ ├── consumer.py (base Consumer class: connect/prefetch/handle/nack-retry) +│ │ │ └── messages.py (pydantic: DocumentUploaded, DocumentExtracted) +│ │ ├── s3/ +│ │ │ ├── port.py (Storage Protocol: put/get/stat/delete/presign) +│ │ │ └── minio_storage.py (MinIO adapter, bucket init, key builders) +│ │ ├── llm/ +│ │ │ ├── port.py (LLMProvider Protocol + AnalysisResult dataclass) +│ │ │ ├── ollama_cloud.py (ports prototype llm_client: repair-loop + 429 fallback) +│ │ │ └── factory.py (provider selection by LLM_PROVIDER env) +│ │ ├── analysis/ +│ │ │ ├── __init__.py +│ │ │ ├── extractor.py (← from contract_check/extractor.py) +│ │ │ ├── chunker.py (← from contract_check/chunker.py) +│ │ │ ├── checklist.py (← from contract_check/checklist.py) +│ │ │ ├── report_schema.py (← from contract_check/report_schema.py) +│ │ │ ├── ocr.py (new — pytesseract via pymupdf rasterize, <100 chars trigger) +│ │ │ └── analyzer.py (new — orchestrate chunk→LLM→merge→dedupe→sort→markdown) +│ │ ├── credits.py (reserve_credit atomic UPDATE; refund_credit idempotent + policy) +│ │ └── tokens.py (service-token hash/verify; FastAPI dependency) +│ ├── api/ (new — FastAPI image) +│ │ ├── __init__.py +│ │ ├── __main__.py (uvicorn entrypoint) +│ │ ├── app.py (create_app: middleware, routes, lifespan) +│ │ ├── deps.py (db session, s3, publisher, service-token dep, api-key dep, rate-limit) +│ │ ├── services.py (shared upload→reserve→MinIO→publish logic; used by documents + b2b) +│ │ ├── routes/ +│ │ │ ├── health.py (/healthz, /readyz) +│ │ │ ├── documents.py (POST /api/v1/documents — upload→MinIO→publish, reserve) +│ │ │ ├── reports.py (GET /api/v1/reports/{id} — 202+stage or 200+md) +│ │ │ ├── me.py (GET /api/v1/me — credits balance) +│ │ │ ├── metrics.py (/metrics — prometheus) +│ │ │ └── b2b.py (X-API-Key: POST /analyze, GET /b2b/reports, /b2b/usage, /b2b/keys CRUD) +│ │ └── middleware.py (correlation_id inject, request metrics, Sentry) +│ ├── worker_extract/ (new — CPU image: pymupdf + tesseract) +│ │ ├── __init__.py +│ │ ├── __main__.py (entrypoint: start consumer + metrics server) +│ │ ├── handler.py (handle DocumentUploaded: MinIO dl→extract→(ocr)→upload text→publish) +│ │ ├── extract_document.py (extraction/OCR orchestration helper) +│ │ └── consumer.py (wire handler into core.mq.consumer base) +│ ├── worker_analyze/ (new — I/O image: LLM provider) +│ │ ├── __init__.py +│ │ ├── __main__.py +│ │ ├── handler.py (handle DocumentExtracted: text dl→chunk→LLM→validate→save report→done) +│ │ └── consumer.py +│ ├── bot/ (new — aiogram adapter, HTTP-only) +│ │ ├── __init__.py +│ │ ├── __main__.py +│ │ ├── config.py (BotSettings: BOT_TOKEN, API_URL, BOT_SERVICE_TOKEN, poll tuning) +│ │ ├── client.py (ApiClient: typed httpx wrapper; ApiError/NoCreditsError/...) +│ │ └── handlers.py (/start, doc upload→POST api, poll→send report) +│ └── prototype/ (moved from contract_check/prototype.py — kept as benchmark) +│ ├── __init__.py +│ └── __main__.py (stage-0 CLI, imports updated to core.analysis.*) +└── tests/ + ├── conftest.py (fixtures: testcontainers pg/rabbit/minio) + ├── unit/ + │ ├── test_chunker.py + │ ├── test_extractor.py + │ ├── test_checklist_report.py + │ ├── test_llm_ollama_cloud.py (respx: 200 / 429-fallback / repair) + │ ├── test_credits.py + │ ├── test_messages.py + │ ├── test_rate_limit.py + │ ├── test_bot_client.py + │ └── test_bot_boundary.py (static AST: bot must not import core.db/s3/llm/mq/credits) + └── integration/ + ├── conftest.py (seed service-token + api-key; testcontainers wiring) + ├── test_upload_pipeline.py (POST /documents → message on extract.q) + ├── test_extract_worker.py (DocumentUploaded → DocumentExtracted published) + ├── test_analyze_worker.py (DocumentExtracted → Report saved, status done) + ├── test_credits_db.py (reserve/refund against real Postgres) + └── test_b2b_api.py (X-API-Key: 202 → poll report; 401/429 branches) +``` + +**Import rule (hexagonal boundary, enforced in review):** + +- `core/*` may import anything. +- `api/*`, `worker_extract/*`, `worker_analyze/*` import only `core/*`. +- `bot/*` imports only `httpx` + its own modules. **It must NOT import + `core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`.** It speaks to + the api over HTTP only. (Add a ruff/flake8 import-forbidden rule or a unit + test that asserts this.) +- `prototype/*` imports `core.analysis.*` but is otherwise standalone (no DB/MQ). + +--- + +## 5. RabbitMQ topology (full spec) + +No plugins required. Pure direct exchanges + TTL/DLX for retry. All +declarations in `core/mq/topology.py` and idempotent (declare on every +service start). + +### Exchanges (direct) + +| Name | Type | Purpose | +|---|---|---| +| `contracts.x` | direct | Main exchange. Routing keys: `extract`, `analyze`. | +| `contracts.retry.x` | direct | DLX target of main queues; routing keys: `retry.extract`, `retry.analyze`. | + +### Queues + +| Name | Type | Args | Bound (exchange / rk) | Consumers | +|---|---|---|---|---| +| `extract.q` | **quorum** | `x-dead-letter-exchange=contracts.retry.x`, `x-dead-letter-routing-key=retry.extract` | contracts.x / `extract` | worker-extract | +| `analyze.q` | **quorum** | `x-dead-letter-exchange=contracts.retry.x`, `x-dead-letter-routing-key=retry.analyze` | contracts.x / `analyze` | worker-analyze | +| `extract.retry.q` | classic | `x-dead-letter-exchange=contracts.x`, `x-dead-letter-routing-key=extract`; per-message `expiration` | contracts.retry.x / `retry.extract` | none (delay slot) | +| `analyze.retry.q` | classic | `x-dead-letter-exchange=contracts.x`, `x-dead-letter-routing-key=analyze`; per-message `expiration` | contracts.retry.x / `retry.analyze` | none (delay slot) | +| `extract.dlq` | **quorum** | — | — | none (manual requeue) | +| `analyze.dlq` | **quorum** | — | — | none (manual requeue) | + +### Flow & retry mechanics + +1. **api** publishes `DocumentUploaded` to `contracts.x` rk `extract` + (persistent, `correlation_id` UUID in both message id and + `headers["x-correlation-id"]`). +2. **worker-extract** consumes from `extract.q` with `prefetch=1..N` (config, + default 1 — extraction is CPU-bound). On success: upload extracted text + to MinIO, publish `DocumentExtracted` to `contracts.x` rk `analyze`, + ack. On failure: see retry below. +3. **worker-analyze** consumes from `analyze.q` with `prefetch=3` (mirrors + Ollama Pro concurrency). On success: save report, `status=done`, ack. + On failure: retry. +4. **Retry (nack):** the base consumer does **not** use `basic.nack(requeue=True)` + (instant re-redelivery, no backoff). Instead it publishes a copy of the + message to the matching `*.retry.q` with: + - `headers["x-attempt"] = attempt + 1` + - `expiration = str(int(BASE_DELAY_MS * 2 ** attempt))` (e.g. + `BASE_DELAY_MS=2000` → 2s, 4s, 8s, 16s, 32s) + - then `basic_ack` the original. + When the retry queue's per-message TTL expires, its DLX bounces the + message back to `contracts.x` with rk `extract`/`analyze` → re-enters the + main quorum queue. Clean, plugin-free, exponential. +5. **Poison (max attempts):** when `headers["x-attempt"] >= MAX_ATTEMPTS` + (default 5), the consumer publishes to `extract.dlq` / `analyze.dlq` + instead of retry, acks the original, sets `jobs.dlq=true`, + `jobs.last_failure_class`, transitions `documents.status=failed`, and + calls `refund_credit(document_id, failure_class)` per policy (§8). +6. **Manual requeue:** a CLI script `scripts/mq_requeue.py` (or mgmt UI) + moves a DLQ message back to the main exchange with reset attempt. + +### Prefetch & concurrency + +- worker-extract: RabbitMQ prefetch caps concurrent extraction jobs (CPU + bound; default 1, tune to CPU count). +- worker-analyze: prefetch caps concurrent LLM jobs (default 3, mirrors + Ollama Pro). **Additionally** keep the in-process `asyncio.Semaphore` + from the prototype `llm_client` — prefetch caps *jobs*, the semaphore + caps *parallel chunk requests within a job*. Both are needed for long + multi-chunk contracts. + +### Idempotency + +Every handler starts by re-reading `documents.status` (and `jobs` row) by +`correlation_id`/`document_id`. If already terminal (`done`/`failed`) or the +job is mid-flight by another consumer, ack and exit — do not re-run the LLM, +do not double-refund. This preserves the existing idempotency invariant from +T-E1-008 across the queue pivot. + +### Message schemas (`core/mq/messages.py`, pydantic v2) + +```python +class DocumentUploaded(BaseModel): + correlation_id: UUID + document_id: UUID + user_id: UUID + s3_key: str # users/{uid}/docs/{did}.{ext} + filename: str + mime: str + attempt: int = 0 + +class DocumentExtracted(BaseModel): + correlation_id: UUID + document_id: UUID + user_id: UUID + extracted_s3_key: str # users/{uid}/docs/{did}.txt + char_count: int + ocr_used: bool + attempt: int = 0 +``` + +RabbitMQ `headers`: `x-correlation-id`, `x-attempt`, `x-origin` (api | +worker-extract). `content_type=application/json`, `delivery_mode=2` +(persistent). Validate on consume with the pydantic model; on validation +error → `.dlq` immediately with `failure_class=infra`. + +--- + +## 6. MinIO (object storage) + +### Bucket & keys + +- One bucket per env: `contract-check-docs` (configurable, `S3_BUCKET`). +- Layout: `users/{user_id}/docs/{document_id}.{ext}` (original upload) and + `users/{user_id}/docs/{document_id}.txt` (extracted text, written by + worker-extract, read by worker-analyze). +- Key builders in `core/s3/minio_storage.py`: `original_key(uid, did, ext)`, + `extracted_key(uid, did)`. Never construct keys ad-hoc. + +### Upload path (proxy through API) + +`POST /api/v1/documents` (multipart): the api validates mime/size, reserves +the credit (§8), writes the blob to MinIO via the Storage port, creates the +`documents` row (`status=queued`), inserts a `jobs` row, publishes +`DocumentUploaded`, returns `202 + {document_id, correlation_id}`. Adapters +never see S3 credentials. Presigned URLs are a future optimization only. + +### Initialization + +A one-shot `minio-init` service in compose runs `mc alias set ... && mc mb +... && mc anonymous set none ...` on boot. Idempotent. Creates the bucket +before api/worker start. Alternatively `core/s3/minio_storage.py` does +`ensure_bucket()` lazily on first use — keep both; lazy is the safety net. + +### Retention (152-ФЗ friendliness) + +- MinIO **ILM lifecycle rule**: expire objects under `users/*/docs/*` after + `DOC_RETENTION_DAYS` (default 7, configurable). Configured via `mc ilm + add/queue` in `minio-init`, or via the MinIO console. The extracted `.txt` + may share the rule or a longer one (`TEXT_RETENTION_DAYS`, default 30). +- The **report** (findings + markdown) lives in **Postgres**, not MinIO, so + it survives the raw-doc purge. This is the 152-ФЗ lever: raw contract + text leaves the system on a schedule, only the structured findings stay. + +### Encryption / versioning + +- SSE-S3 (server-side encryption with MinIO-managed keys) **on** for + production env, off for dev — toggle via `S3_SERVER_SIDE_ENCRYPTION`. +- Versioning **off** (documents are write-once; versioning adds cost and + complicates TTL expiry). + +--- + +## 7. Postgres schema (initial Alembic migration) + +Six tables. `status`/`queue`/`adapter` columns are `TEXT + CHECK` (not +Postgres enums) so migrations are additive — matches the existing convention +noted in `IMPLEMENTATION_PLAN.md` §1.2. + +```sql +-- users +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + telegram_id BIGINT UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + credits_left INTEGER NOT NULL DEFAULT 0, + CONSTRAINT users_credits_nonneg CHECK (credits_left >= 0) +); + +-- documents +CREATE TABLE documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + s3_key TEXT NOT NULL, + extracted_s3_key TEXT, + filename TEXT NOT NULL, + mime TEXT NOT NULL, + bytes BIGINT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued','extracting','ocr','analyzing','done','failed')), + stage TEXT, -- sub-stage / human label + refunded BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX documents_user_created_idx ON documents (user_id, created_at DESC); +CREATE INDEX documents_status_idx ON documents (status); + +-- reports +CREATE TABLE reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL UNIQUE REFERENCES documents(id) ON DELETE CASCADE, + content_json JSONB NOT NULL, + markdown TEXT NOT NULL, + model_used TEXT, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + eval_tokens INTEGER NOT NULL DEFAULT 0, + latency_ms INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- jobs (RabbitMQ correlation; one document has up to 2 jobs: extract + analyze) +CREATE TABLE jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + correlation_id UUID NOT NULL, + queue TEXT NOT NULL CHECK (queue IN ('extract','analyze')), + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 5, + last_failure_class TEXT, + last_error TEXT, + dlq BOOLEAN NOT NULL DEFAULT FALSE, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','running','retrying','dlq','done')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX jobs_correlation_idx ON jobs (correlation_id); +CREATE INDEX jobs_document_idx ON jobs (document_id); + +-- service_tokens (per-adapter auth) +CREATE TABLE service_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, -- "bot-prod", "web-prod" + token_hash TEXT NOT NULL, -- sha256 hex of the bearer secret + adapter TEXT NOT NULL CHECK (adapter IN ('bot','web','cli')), + revoked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ +); + +-- invoices (STUB — no ЮKassa logic this refactor; forward-compatible schema) +CREATE TABLE invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount INTEGER NOT NULL, -- kopecks + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft','pending','succeeded','cancelled','refunded')), + provider TEXT, -- 'yookassa' + external_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + paid_at TIMESTAMPTZ +); +CREATE INDEX invoices_user_idx ON invoices (user_id, created_at DESC); +``` + +**Alembic rules:** `env.py` uses the async engine; migrations are +hand-written (autogenerate used only to draft, never committed as-is); +`up` and `down` both clean; CI runs `alembic upgrade head` then +`alembic downgrade base` on a throwaway DB. + +--- + +## 8. Credits & refund policy (billing invariants) + +The reserve-on-enqueue invariant from T-E1-003 **survives the queue pivot +unchanged**. RabbitMQ only changes *who runs the job*, not *when the credit +moves*. + +### Reserve (api, on enqueue, synchronous) + +```python +async def reserve_credit(session, user_id) -> bool: + row = await session.execute( + text(""" + UPDATE users SET credits_left = credits_left - 1 + WHERE id = :u AND credits_left > 0 + RETURNING credits_left + """), + {"u": user_id}, + ) + return row.first() is not None +``` + +Atomic, never negative. If `False`, api returns `402 Payment Required` +synchronously — no MinIO write, no message published. + +### Refund (worker, on terminal failure, idempotent) + +Guarded by `documents.refunded` so a duplicate message / requeue cannot +double-refund: + +```python +NON_REFUNDABLE_INFRA_ONLY = {"extraction_failed"} # user-garbage input + +async def refund_credit(session, document_id, failure_class, policy) -> bool: + if policy == "infra_only" and failure_class in NON_REFUNDABLE_INFRA_ONLY: + return False # user pays for undetectable garbage + result = await session.execute( + text(""" + UPDATE users + SET credits_left = credits_left + 1 + WHERE id = (SELECT user_id FROM documents + WHERE id = :d AND refunded = FALSE) + RETURNING id + """), + {"d": document_id}, + ) + if result.first() is None: + return False # already refunded (idempotent no-op) + await session.execute( + text("UPDATE documents SET refunded = TRUE WHERE id = :d"), + {"d": document_id}, + ) + return True +``` + +### Failure classes (`core/db/enums.py`) + +| Class | Meaning | Refundable under `infra_only`? | +|---|---|---| +| `extraction_failed` | pymupdf couldn't extract (>100 chars expected, got less) and OCR also failed | **No** (likely user garbage) | +| `ocr_failed` | Tesseract raised | Yes | +| `llm_quota` | 429 / quota on both primary and fallback | Yes | +| `llm_invalid_output` | model returned unrepairable JSON | Yes | +| `llm_timeout` | Ollama timed out past retries | Yes | +| `infra` | DB/S3/MQ connectivity, unhandled exception | Yes | +| `unknown` | anything not classified | Yes | + +Set on the `jobs.last_failure_class` column. `REFUND_POLICY` env selects +`all` (default) or `infra_only`. + +--- + +## 9. LLM provider port + Ollama Cloud adapter + +The prototype's `llm_client.py` (bearer httpx, `format: json-schema`, +5xx backoff, 429→fallback, repair-loop) is excellent. It becomes the first +adapter behind a port so we can later swap in self-hosted Ollama, +GigaChat, or YandexGPT without touching `analyzer.py`. + +### Port (`core/llm/port.py`) + +```python +from typing import Protocol, Sequence +from dataclasses import dataclass + +@dataclass(slots=True) +class AnalysisResult: + findings: list # list[Finding] (from core.analysis.report_schema) + model_used: str + fell_back: bool + repaired: bool + prompt_tokens: int + eval_tokens: int + latency_sec: float + +class LLMProvider(Protocol): + async def analyze(self, text: str, *, checklist: str) -> AnalysisResult: ... + async def aclose(self) -> None: ... +``` + +### Adapter (`core/llm/ollama_cloud.py`) + +Move the existing `OllamaCloudClient` + `ChatResult` + `_QuotaError` + +`_run_with_fallback` + `_run_repair_loop` + `_post_chat` verbatim, then add +an `analyze(text, *, checklist)` method that: + +1. chunks the text via `core.analysis.chunker.chunk_text`, +2. builds the system/user prompts (from `prototype.SYSTEM_PROMPT` + + `build_user_prompt`, lifted into `core/analysis/analyzer.py`), +3. fans out chunk requests under the existing `asyncio.Semaphore`, +4. merges/dedupes/sorts findings (lift `dedupe_findings` + `sort_findings` + into `core/analysis/analyzer.py`), +5. returns `AnalysisResult`. + +Map adapter-internal failures to `FailureClass`: + +- `_QuotaError` escapes → `llm_quota` +- `ValidationError` after repair → `llm_invalid_output` +- `httpx.TimeoutException` → `llm_timeout` +- anything else → `infra` + +### Factory (`core/llm/factory.py`) + +```python +def build_llm_provider(settings) -> LLMProvider: + match settings.llm_provider: + case "ollama_cloud": return OllamaCloudProvider(settings) + case _: raise ValueError(f"unknown LLM_PROVIDER={settings.llm_provider!r}") +``` + +`LLM_PROVIDER` env (default `ollama_cloud`). Future providers register here. + +--- + +## 10. Durability & HA posture (single VPS, HA-ready) + +**Confirmed posture:** HA-*ready* on one VPS, not HA-*running*. True +mirrored queues and Postgres replication require multiple hosts; on one VPS +we get **durability** (survives reboot/crash) and **recoverability** +(point-in-time), and the path to true HA is a topology change, not a code +change. + +### RabbitMQ — quorum queues + +- `extract.q`, `analyze.q`, `extract.dlq`, `analyze.dlq` are `x-queue-type: + quorum`. Quorum queues persist every message to disk and use Raft. On a + single node they are durable; the moment a 3-node RabbitMQ cluster is + added (compose: `rabbitmq@rmq1/2/3`), the same queues replicate with no + application code change. +- Retry queues are `classic` (transient delay slots; safe to lose on + catastrophic failure — the originating message is already acked and + tracked in `jobs`). +- Enable **publisher confirms** on the api publisher (await confirm per + publish; if nacked/timeout, fail the request before telling the user + 202). A paid job's message must never silently vanish. + +### Postgres — replication-ready + +- `wal_level=replica`, `archive_mode=on`, `archive_command` to a mounted + volume (or `pg_backrest` later). Gives PITR. +- A physical replication slot + hot standby is a documented add-host step + (add `postgres-replica` service, `primary_conninfo`). Patroni/repmgr when + automated failover is wanted. +- Daily `pg_dump` cron in compose (sidecar or host cron) — baseline backup. + +### Volumes & restart + +- Named volumes for `pgdata`, `rabbitmq`, `minio`, `redis`, `prometheus`, + `grafana`, `tempo`. Bind-mount only `./deploy` configs and cert dirs. +- `restart: unless-stopped` on every long-running service. +- `depends_on: condition: service_healthy` everywhere with real healthchecks + (pg `pg_isready`, rabbit `rabbitmq-diagnostics ping`, minio `mc ready`, + redis `redis-cli ping`). + +### Scale-out checklist (when leaving one VPS) + +1. Cluster RabbitMQ to 3 nodes across hosts (quorum queues auto-replicate). +2. Add Postgres replica + Patroni (automated failover). +3. Run ≥2 api, ≥2 worker-extract, ≥2 worker-analyze behind the same + Rabbit/PG/MinIO (stateless services scale horizontally for free). +4. Move MinIO to distributed mode (≥4 nodes, erasure coding). +5. Reconsider Nginx → managed LB. + +None of 1–5 requires touching `core/` application code — only compose/infra. + +--- + +## 11. Configuration (`core/config.py`, pydantic-settings) + +12-factor: all config via env. Typed via `pydantic-settings`. Base `Settings` ++ per-service subclasses. No hardcoded model names, prompts, delays. + +### Base (shared by all services) + +| Env | Default | Notes | +|---|---|---| +| `ENV` | `dev` | dev/staging/prod — toggles TLS, sentry sample rate | +| `LOG_LEVEL` | `INFO` | structlog level | +| `LOG_FORMAT` | `json` | `json` (prod/staging) \| `console` (dev) — explicit override of env-based default | +| `APP_VERSION` | `unknown` | added to every log line; set at build/deploy time | +| `DATABASE_URL` | (required) | `postgresql+asyncpg://...` | +| `REDIS_URL` | `redis://redis:6379/0` | rate limit / sessions (future) | +| `RABBITMQ_URL` | (required) | `amqp://guest:guest@rabbitmq:5672//` | +| `MQ_PREFETCH_EXTRACT` | `1` | CPU-bound | +| `MQ_PREFETCH_ANALYZE` | `3` | mirrors Ollama Pro concurrency | +| `MQ_MAX_ATTEMPTS` | `5` | before DLQ | +| `MQ_RETRY_BASE_MS` | `2000` | exponential base | +| `S3_ENDPOINT_URL` | (required) | MinIO URL | +| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | (required) | | +| `S3_BUCKET` | `contract-check-docs` | | +| `S3_REGION` | `us-east-1` | MinIO default | +| `S3_SERVER_SIDE_ENCRYPTION` | `false` | true in prod | +| `DOC_RETENTION_DAYS` | `7` | MinIO ILM expiry | +| `TEXT_RETENTION_DAYS` | `30` | extracted `.txt` expiry | +| `REFUND_POLICY` | `all` | `all` \| `infra_only` | +| `SENTRY_DSN` | (empty) | if set, init sentry | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | (empty) | OTel collector | +| `OTEL_SERVICE_NAME` | per-service | overridden in each service settings | + +### LLM + +| Env | Default | Notes | +|---|---|---| +| `LLM_PROVIDER` | `ollama_cloud` | factory selection | +| `OLLAMA_HOST` | (required if provider=ollama_cloud) | `https://ollama.com` for Ollama Cloud; `http://localhost:11434` for self-hosted | +| `OLLAMA_API_KEY` | (required) | bearer | +| `OLLAMA_MODEL` | `qwen2.5:14b` | primary; must be a model available at the configured host | +| `OLLAMA_FALLBACK_MODEL` | `qwen2.5:7b` | 429 fallback; must also exist on the host | +| `OLLAMA_TEMPERATURE` | `0.2` | | +| `OLLAMA_NUM_PREDICT` | `3072` | | +| `OLLAMA_TIMEOUT` | `120` | seconds | +| `OLLAMA_MAX_CONCURRENCY` | `3` | in-process semaphore (per analyze worker) | +| `CHUNK_SIZE_CHARS` | `10000` | chunker | + +### API + +| Env | Default | +|---|---|---| +| `API_HOST` | `0.0.0.0` | +| `API_PORT` | `8000` | +| `API_METRICS_PORT` | `9100` | +| `B2B_DEFAULT_RATE_LIMIT_RPS` | `3` (per API key; mirrors Ollama Pro concurrency, overridable per `api_keys.rate_limit_rps`) | +| `CORS_ORIGINS` | (empty, future web) | + +### Auth (JWT + Telegram identity verification) + +| Env | Default | Notes | +|---|---|---| +| `TELEGRAM_BOT_TOKEN` | (empty) | used by API to verify Login Widget / Mini App signatures | +| `JWT_SECRET` | (required) | HS256 secret for signing user JWTs; generate with `openssl rand -hex 32` | +| `JWT_ALGORITHM` | `HS256` | | +| `JWT_ACCESS_TTL_MINUTES` | `1440` (24h) | access-token lifetime; tune per env | + +### Bot + +| Env | Default | +|---|---|---| +| `BOT_TOKEN` | (required) | +| `API_URL` | `http://api:8000` | +| `BOT_SERVICE_TOKEN` | (required — bearer for adapter auth to `/api/v1/auth/telegram/bot`, looked up against `service_tokens`) | + +### Prototype (standalone benchmark image) + +Same Ollama env as above; no DB/MQ/S3 env needed. + +--- + +## 12. Docker — images & compose + +### Per-service Dockerfile pattern (uv, multi-stage, py3.13) + +Dockerfiles live in `srv//Dockerfile` (one per service). Common shape +(shown for api): + +```dockerfile +# syntax=docker/dockerfile:1 +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv +WORKDIR /app +COPY pyproject.toml uv.lock ./ +COPY README.md ./ +COPY src ./src +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-default-groups --group api + +FROM python:3.13-slim AS runtime +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/app/.venv/bin:$PATH +WORKDIR /app +COPY --from=builder /app/.venv /app/.venv +# Tesseract ONLY in srv/worker-extract/Dockerfile: +# RUN apt-get update && apt-get install -y --no-install-recommends \ +# tesseract-ocr tesseract-ocr-rus tesseract-ocr-eng \ +# && rm -rf /var/lib/apt/lists/* +EXPOSE 8000 +CMD ["python", "-m", "contract_check.api"] +``` + +Per-service differences (`uv` uses PEP 735 dependency-groups — see `pyproject.toml`): + +| Dockerfile | Extra installed | Runtime apt | CMD | Expose | +|---|---|---|---|---| +| `srv/api/Dockerfile` | `--group api` | none | `python -m contract_check.api` | 8000, 9100 | +| `srv/worker-extract/Dockerfile` | `--group extract` | tesseract-ocr, -rus, -eng | `python -m contract_check.worker_extract` | 9101 | +| `srv/worker-analyze/Dockerfile` | `--group analyze` | none | `python -m contract_check.worker_analyze` | 9102 | +| `srv/bot/Dockerfile` | `--group bot` | none | `python -m contract_check.bot` | — | +| `srv/prototype/Dockerfile` | `--group prototype` | none | `python -m contract_check.prototype` | — | + +The bot image is the leanest (no DB driver, no S3 client, no pymupdf). The +analyze image has httpx but no tesseract/pymupdf. The extract image is the +heaviest (tesseract + language packs). This is the "fine-tuned deps per +service" payoff. + +### pyproject.toml dependency-groups (actual — PEP 735) + +The project uses `[dependency-groups]` (not `[project.optional-dependencies]`). +Core deps (installed in every image) stay minimal; service-specific libs live +in groups so each Docker image runs `uv sync --no-default-groups --group `. +Shared `db`/`mq`/`s3`/`obs` groups are `include-group`-ed by the service groups +that need them. Sketch (see `pyproject.toml` for the authoritative list): + +```toml +[project] +name = "contract-check" +requires-python = ">=3.13" +dependencies = [ + "pydantic>=2.7", "pydantic-settings>=2.3", "structlog>=24.1", + "python-dotenv>=1.0", "httpx[http2]>=0.27", +] + +[dependency-groups] +db = ["sqlalchemy>=2.0", "asyncpg>=0.29", "alembic>=1.13"] +mq = ["aio-pika>=9.4"] +s3 = ["minio>=7.2"] +obs = ["prometheus-client>=0.20", "sentry-sdk>=2", + "opentelemetry-sdk>=1.24", "opentelemetry-exporter-otlp>=1.24"] + +api = [{ include-group = "db" }, { include-group = "mq" }, + { include-group = "s3" }, { include-group = "obs" }, + "fastapi>=0.110", "uvicorn[standard]>=0.29", "python-multipart>=0.0.9", + "redis>=5.0", + "opentelemetry-instrumentation-fastapi>=0.45b0", + "opentelemetry-instrumentation-asgi>=0.45b0"] +extract = [{ include-group = "db" }, { include-group = "mq" }, + { include-group = "s3" }, { include-group = "obs" }, + "pymupdf>=1.24", "pytesseract>=0.3.10", "pillow>=10"] +analyze = [{ include-group = "db" }, { include-group = "mq" }, + { include-group = "s3" }, { include-group = "obs" }, + "opentelemetry-instrumentation-httpx>=0.45b0"] +bot = ["aiogram>=3.4"] +prototype = ["pymupdf>=1.24", "python-docx>=1.1"] +dev = [{ include-group = "api" }, { include-group = "extract" }, + { include-group = "analyze" }, { include-group = "bot" }, + { include-group = "prototype" }, + "pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21", "ruff>=0.5", + "mypy>=1.10", "anyio>=4", "aiosqlite>=0.20", + "testcontainers[rabbitmq,postgres,minio]>=4", "asgi-lifespan>=2.1.0"] +``` + +### docker-compose.yml structure + +One file, profiles. Default (`docker compose up`) = infra only. Services +behind `--profile services`. Observability behind `--profile obs`. Edge +behind `--profile edge`. + +``` +services: + # ── INFRA (default profile) ── + postgres: postgres:18-alpine, wal_level=replica, archive, healthcheck, volumes (pgdata, pgwal); host port 15432 + redis: redis:8-alpine, aof, healthcheck, volume; host port 17379 + rabbitmq: rabbitmq:4-management-alpine, healthcheck, volume; AMQP 5672, mgmt UI 15672 + minio: minio/minio, healthcheck, volume, console on :9001; S3 API :9000 + minio-init: one-shot: mc alias + mb + ilm rule; depends_on minio healthy + + # ── SERVICES (profile: services) ── + api: build srv/api/Dockerfile; depends_on pg/rabbit/minio-init healthy; + healthcheck /healthz; ports 8000,9100 + worker-extract: build srv/worker-extract/Dockerfile; depends_on pg/rabbit/minio-init healthy; 9101 + worker-analyze: build srv/worker-analyze/Dockerfile; depends_on pg/rabbit/minio-init healthy; 9102 + bot: build srv/bot/Dockerfile; depends_on api healthy (NOT pg/rabbit) + + # ── OBSERVABILITY (profile: obs) ── PLANNED (T-E1-010) + prometheus: prom/prometheus; scrape api:9100, extract:9101, analyze:9102 + grafana: grafana/grafana; provisioned datasources + starter dashboards + otel-collector: otel/opentelemetry-collector-contrib; receives OTLP + tempo: grafana/tempo; trace storage + + # ── EDGE (profile: edge) ── PLANNED (T-E1-009) + nginx: nginx:alpine; reverse proxy → api; TLS via certbot + certbot: certbot/certbot; renew cron sidecar + +volumes: { pgdata, pgwal, redisdata, rabbitmq, minio } +``` + +`bot` depends on `api` healthy (not on infra) — it speaks HTTP to the api, +enforcing the adapter boundary even in dependency ordering. + +--- + +## 13. Observability + +### Structured logging (`core/logging.py`) + +- `structlog` with JSON renderer in prod, console renderer in dev. +- A `correlation_id` `contextvars.ContextVar` is the spine of every log line. +- **api** middleware reads `X-Correlation-ID` header or mints a UUID4, sets + the contextvar, and the api publisher stamps `headers["x-correlation-id"]` + on every published message. +- **workers** read `headers["x-correlation-id"]` on consume and set the same + contextvar before handling. So a single upload's logs trace + api → rabbit → worker-extract → worker-analyze → DB under one ID. +- OTel baggage/span context propagates the same ID for distributed traces. + +### Sentry (`core/sentry.py`) + +- `sentry_sdk.init(dsn=SENTRY_DSN, environment=ENV, traces_sample_rate=...)` + in each service's entrypoint. +- FastAPI integration in api, asyncio integration in workers. +- Sample rate: 1.0 in dev, 0.1 in prod (env-driven). + +### Prometheus (`core/metrics.py` + `/metrics`) + +- api: `http_requests_total`, `http_request_duration_seconds`, + `documents_uploaded_total`, `credits_reserved_total`, `mq_publish_total`. +- worker-extract: `extract_jobs_total`, `extract_duration_seconds`, + `ocr_used_total`, `extract_failures_total{class=...}`, expose on `:9101`. +- worker-analyze: `analyze_jobs_total`, `analyze_duration_seconds`, + `llm_tokens_total{kind=prompt|eval}`, `llm_fell_back_total`, + `refund_total{policy=...}`, expose on `:9102`. +- Workers run `prometheus_client.start_http_server(port)` in a background + thread alongside the async consumer. + +### Grafana + +- Provisioned Prometheus + Tempo datasources. +- Starter dashboard: queue depth (`rabbitmq_queue_messages`), job duration + histogram, LLM tokens/min, refund rate, 429/fallback rate. + +### OpenTelemetry (`core/telemetry.py`) + +- OTLP exporter to `otel-collector` → Tempo. +- Auto-instrument FastAPI (api), httpx (all outbound, incl. Ollama calls). +- Spans carry the same `correlation_id` as logs. Trace from HTTP request → + RabbitMQ publish → consume → LLM call is one trace tree. + +--- + +## 14. Testing strategy + +### Unit (`tests/unit/`, fast, no I/O) + +- `test_chunker.py`, `test_extractor.py` — port existing, parametrized. +- `test_llm_ollama_cloud.py` — **respx** mocks: 200 happy, 429→fallback, + invalid-JSON→repair→success, invalid-JSON→repair→fail, timeout. +- `test_credits_policy.py` — `reserve_credit` never negative; `refund_credit` + idempotent (double-call refunds once); `infra_only` skips + `extraction_failed`; concurrency: 5 parallel reserves against 1 credit → + exactly 1 succeeds. +- `test_messages.py` — pydantic round-trip + header validation. +- **adapter-boundary test**: `bot/` must not import any `core.db`/`core.s3`/ + `core.llm`/`core.mq`/`core.credits` symbol (static AST check). + +### Integration (`tests/integration/`, testcontainers, slow) + +- `conftest.py` fixtures start **real** Postgres + RabbitMQ + MinIO via + `testcontainers` (one container each per session), apply migrations, wire + to a throwaway MinIO bucket and RabbitMQ vhost. +- `test_upload_pipeline.py` — `POST /api/v1/documents` (TestClient) → + credit reserved, blob in MinIO, `DocumentUploaded` lands on `extract.q` + (assert via `aio-pika` consumer). +- `test_extract_worker.py` — publish `DocumentUploaded`, run + worker-extract handler in-process → `DocumentExtracted` published, + extracted text in MinIO, `documents.status` advanced. +- `test_analyze_worker.py` — publish `DocumentExtracted` with respx-mocked + Ollama, run worker-analyze handler → report row in Postgres with + `status=done`, markdown contains disclaimer. +- `test_retry_and_dlq.py` — force Ollama 500 repeatedly → message cycles + retry queues with growing TTL, lands on `analyze.dlq` after + `MAX_ATTEMPTS`, `documents.status=failed`, credit refunded per policy. + +Marks: `@pytest.mark.integration` excluded from the default fast run; CI +runs both. `pytest -q` (unit) < 5s; integration as a separate stage. + +### DoD per step + +Every landing step ends with all three green: + +```bash +ruff check . && mypy src && pytest -q # unit, fast +pytest -m integration -q # integration, CI only +``` + +--- + +## 15. API surface (FastAPI) + +All under `/api/v1` (mounted from day one). Three auth modes: + +- **User JWT** (`Authorization: Bearer `) — common token for bot users, + Telegram Login Widget users, and Telegram Mini App users. Issued by + `/api/v1/auth/telegram/*` after verifying the Telegram identity proof + (`core/auth.py`). User endpoints rely on `api/deps.py:require_current_user`. +- **Service token** (`Authorization: Bearer `) — adapter-level auth, + validated against `service_tokens` (`core/tokens.py`). Used only by the + bot adapter to call `/api/v1/auth/telegram/bot` and exchange a verified + `telegram_id` for a user JWT. +- **B2B API key** (`X-API-Key`) — validated against `api_keys.key_hash` + (`core/api_keys.py` + `api/deps.py:require_api_key`) with per-key token-bucket + rate-limit (`core/rate_limit.py`). Used by external B2B clients. + +Health/metrics exempt from auth. + +### Auth endpoints (`api/routes/auth.py`) + +| Method | Path | Auth | Behavior | +|---|---|---|---| +| POST | `/api/v1/auth/telegram/bot` | service token | bot exchanges verified `telegram_id` for a user JWT | +| POST | `/api/v1/auth/telegram/web` | — | verify Telegram Login Widget payload → issue user JWT | +| POST | `/api/v1/auth/telegram/miniapp` | — | verify Mini App `initData` HMAC → issue user JWT | +| GET | `/api/v1/auth/me` | user JWT | introspect JWT claims | + +### User endpoints (user JWT) + +| Method | Path | Auth | Behavior | +|---|---|---|---| +| GET | `/healthz` | none | 200 liveness (process alive) | +| GET | `/readyz` | none | 200 readiness (DB+Rabbit+MinIO reachable) | +| GET | `/metrics` | none | Prometheus exposition | +| POST | `/api/v1/documents` | user JWT | multipart → reserve credit → MinIO put → row `queued` → publish `DocumentUploaded` → `202 {document_id, correlation_id}`. `402` if no credit. `400` bad mime/size. | +| GET | `/api/v1/documents/{id}` | user JWT | status + stage + filename (for polling UI) | +| GET | `/api/v1/reports/{document_id}` | user JWT | `202 {status, stage}` while not done; `200 {markdown, findings, ...}` when done | +| GET | `/api/v1/me` | user JWT | `{telegram_id, credits_left}` | + +### B2B endpoints (`X-API-Key`, `api/routes/b2b.py`) + +| Method | Path | Auth | Behavior | +|---|---|---|---| +| POST | `/api/v1/analyze` | api key | multipart → reserve owner's credit → publish → `202 {document_id, correlation_id}`; rate-limited (`429`), `401` bad/revoked key | +| GET | `/api/v1/b2b/reports/{document_id}` | api key | scoped to key owner; `202 {status, stage}` or `200 {markdown, findings, ...}` | +| GET | `/api/v1/b2b/usage` | api key | `{monthly_quota, monthly_used, requests_this_month, resets_at}` for the authenticating key | +| POST | `/api/v1/b2b/keys` | user JWT | create key; raw `api_key` returned **once** (only hash stored) | +| GET | `/api/v1/b2b/keys` | user JWT | list owner's keys | +| POST | `/api/v1/b2b/keys/{id}/revoke` | user JWT | revoke (instant auth disable) | +| GET | `/api/v1/b2b/keys/{id}/usage` | user JWT | per-month request counts | + +No synchronous `/analyze` (locked). Adapters poll `/reports/{id}`; the +fine-grained `stage` field powers a progress signal in the bot ("Extracting +text…", "Analyzing…"). SSE/webhook added later. + +--- + +## 16. Worker internals + +### worker-extract (`worker_extract/handler.py`) + +``` +on DocumentUploaded(msg): + if documents.status by msg.document_id is terminal: ack; return + set status=extracting (or ocr), jobs.status=running + download s3_key from MinIO → temp file + try: text = extractor.extract_text(tmp) + ocr_used = False + except ExtractionError (<100 chars, likely scan): + set status=ocr + text = ocr.ocr_pdf(tmp); ocr_used = True + if still <100: raise with failure_class=extraction_failed + upload extracted_key=text to MinIO + publish DocumentExtracted(correlation_id, document_id, extracted_s3_key, + char_count=len(text), ocr_used, attempt) + ack +on failure: consumer-base retry/dlq logic (§5), refund per policy (§8) +``` + +### worker-analyze (`worker_analyze/handler.py`) + +``` +on DocumentExtracted(msg): + if documents.status terminal: ack; return + set status=analyzing, jobs.status=running + text = s3.get(extracted_s3_key) + result = llm_provider.analyze(text, checklist=checklist_for_prompt()) + validate result.findings via ReportPayload (repair already in provider) + markdown = analyzer.render_markdown(result.findings, ...) + save report (content_json, markdown, tokens, latency, model_used) + set documents.status=done + ack +on failure: classify failure_class, retry/dlq, refund per policy +``` + +LLM provider failures bubble as `FailureClass`; the consumer-base catches +and routes to retry/DLQ/refund. + +### Concurrency reminders + +- worker-extract: RabbitMQ prefetch caps jobs (CPU). Tesseract/pymupdf are + sync → run in `asyncio.to_thread`/threadpool within the handler. +- worker-analyze: RabbitMQ prefetch (3) caps jobs; the provider's internal + `asyncio.Semaphore(OLLAMA_MAX_CONCURRENCY=3)` caps parallel chunk LLM + calls within a single multi-chunk contract. Both layers matter. + +--- + +## 17. Bot adapter (`bot/`) + +aiogram 3. Pure HTTP client to the api. **Forbidden imports**: `core.db`, +`core.s3`, `core.llm`, `core.mq`, `core.credits`, `sqlalchemy`, `minio`, +`aio_pika`, `pymupdf`. Enforced by a unit test (§14). + +Flow: + +- `/start` → `GET /api/v1/me` → greeting + credit balance. +- User sends PDF/DOCX → forward multipart to `POST /api/v1/documents` with + `Authorization: Bearer $BOT_SERVICE_TOKEN`. React to `402` (no credit, + polite refusal), `400` (bad format), `202` (acknowledged). +- Poll `GET /api/v1/reports/{id}` with backoff; surface `stage` as a status + message ("Extracting…", "Analyzing…"). +- On done: send markdown report; if >4096 chars, send as `.md` attachment. + Every report carries the disclaimer (bot appends if api omitted). + +No business logic, no direct state. The hexagonal rule. + +--- + +## 18. Edge & deployment + +### Nginx + certbot (`deploy/nginx/`) + +- Nginx reverse-proxies `/{api,v1,healthz,readyz,metrics}` → `api:8000`. +- TLS via certbot; cert files bind-mounted; renew cron sidecar. +- Webhook target for future ЮKassa: `https:///api/v1/webhooks/yookassa`. + +### Single-VPS deploy + +1. VPS (Hetzner/Selectel), Docker + compose installed. +2. Clone, `cp .env.example .env`, fill secrets (DB, Rabbit, MinIO, Ollama, + Sentry, tokens). +3. Seed a `service_tokens` row for the bot (hash of `BOT_SERVICE_TOKEN`) + via a one-shot `python -m contract_check.api seed-token bot bot-prod`. +4. `docker compose --profile services --profile obs --profile edge up -d`. +5. `pg_dump` cron for backups. + +### k8s-ready posture + +- Every service exposes a healthcheck (`/healthz`) and is configurable + purely via env (12-factor). No code change needed to move to k8s; only + manifests/Helm charts (future). + +--- + +## 19. Conventions & rules of the road + +- **Lint/types/tests green per step**: `ruff check . && mypy src && pytest`. +- **No comments** in code (house style — existing prototype has none). +- **Hexagonal boundary**: adapters never import core state/infra. Enforced + by a unit test. +- **Reserve-on-enqueue** invariant is sacred: credit moves on `POST + /documents`, never in the worker. +- **Refund idempotency** is sacred: `documents.refunded` guards it. +- **Config via env**, never hardcoded model/prompt/delay. +- **Every report carries the disclaimer** "не заменяет юриста" — render in + `analyzer.render_markdown`, assert in tests. +- **Migrations hand-written**, `up` and `down` both clean. +- **One image = one entrypoint**; `MODE` dispatcher is dead. +- **Publisher confirms** on; a published-but-unconfirmed message fails the + HTTP request. +- **Correlation ID** flows HTTP → message header → logs → traces. + +--- + +## 20. Landing sequence (incremental, green per step) + +Each step is a verifiable unit. Do not start step N+1 until N is green +(`ruff && mypy && pytest` + relevant integration). + +### Step 1 — Infra + core skeleton + initial migration + +- Compose: postgres (replication-ready), redis, rabbitmq (mgmt UI exposed), + minio, minio-init. All with healthchecks + volumes. Default profile = + infra only. +- `src/contract_check/core/`: config, logging, telemetry, sentry, metrics, + db/models (all 6 tables), db/session, mq/topology, mq/messages, s3/port, + s3/minio_storage, llm/port, analysis/* (migrate extractor/chunker/checklist/ + report_schema from prototype), credits, tokens. +- Alembic: init + initial migration (6 tables). +- Migrate `prototype.py` → `prototype/__main__.py` (update imports to + `core.analysis.*`). +- **DoD**: `docker compose up` infra healthy; `alembic upgrade head` + + `downgrade base` clean; `ruff && mypy && pytest` green (unit tests ported + for chunker/extractor/credits/messages). + +### Step 2 — api image (done) + +- `srv/api/Dockerfile`, `src/contract_check/api/*`: app factory, middleware + (correlation_id, request metrics, Sentry), routes (health, ready, metrics, + documents upload→MinIO→publish with confirms, reports poll with stage, me, + **b2b.py** — X-API-Key endpoints), deps (db session, s3, publisher, + service-token, api-key, rate-limit), services.py (shared upload logic). +- Reserve-on-enqueue; `402` path. +- Seed-token one-shot CLI. +- **DoD**: `POST /api/v1/documents` reserves credit, writes MinIO, publishes + `DocumentUploaded` (verified on extract.q); unit + integration + (`test_upload_pipeline`, `test_b2b_api`) green. + +### Step 3 — worker-extract image (done) + +- `srv/worker-extract/Dockerfile` (tesseract layer), `worker_extract/*`: + handler + consumer wiring + metrics server on :9101. +- `core/analysis/ocr.py`. +- Retry + DLQ via base consumer; failure classification; status updates. +- **DoD**: `test_extract_worker` + `test_retry_and_dlq` green; real PDF in + MinIO → extracted text published on analyze.q. + +### Step 4 — worker-analyze image (done) + +- `srv/worker-analyze/Dockerfile`, `worker_analyze/*`: handler + consumer + + metrics on :9102. +- `core/llm/ollama_cloud.py` (ported) + factory; analyzer orchestration; + report save with JSONB+markdown; refund-on-fail per policy. +- **DoD**: `test_analyze_worker` green (respx-mocked Ollama); end-to-end + upload→report in integration; disclaimer present. + +### Step 5 — bot image (done) + +- `srv/bot/Dockerfile`, `bot/*`: aiogram handlers, HTTP-only. +- Boundary test (no forbidden imports). +- **DoD**: local polling: send PDF → get report; boundary test green. + +### Step 6 (later) — payments, web, dashboards + +- ЮKassa + `invoices` logic + recurring (separate effort). +- React SPA + Telegram Login + SSE/webhook delivery. +- Grafana dashboards polished; Tempo/Jaeger trace UI. + +--- + +## 21. Open questions / deferred + +- **Payments (ЮKassa)** — next focused iteration after core lands. +- **Web SPA + Telegram Login auth** — stage 2. +- **B2B API keys + rate limit** — stage 3 (done; Redis + `core/api_keys.py` + + `core/rate_limit.py` + `api/routes/b2b.py`). +- **Multi-host HA** — when load justifies; path documented in §10. +- **Self-hosted Ollama / GigaChat** — behind the provider port when 152-ФЗ + forces it or Ollama Cloud overage bites. +- **OCR backend** (Yandex Vision) — behind an `OCRBackend` port later. +- **DLQ admin UI** — a small management route/script for requeue; mgmt UI + suffices initially. + +--- + +## 22. Quick reference — file-to-rule index + +- "What's the queue topology?" → §5, `core/mq/topology.py` +- "How does a credit move?" → §8, `core/credits.py` +- "What statuses can a document have?" → §7, `core/db/enums.py` +- "How is a job retried?" → §5 (retry mechanics), `core/mq/consumer.py` +- "How do I add a new LLM provider?" → §9, `core/llm/factory.py` +- "What can the bot import?" → §4 (boundary), §17, `tests/unit/test_bot_boundary.py` +- "Where do env vars live?" → §11, `core/config.py`, `.env.example` +- "How is this deployed?" → §18 +- "How do we scale to HA?" → §10 +- "What's the next thing to build?" → §20, step 1 +- **"How does the B2B API work?"** → §15, `api/routes/b2b.py`, `core/api_keys.py` +- **"How is rate limiting enforced?"** → §15, `core/rate_limit.py` +- **"Where are the Dockerfiles?"** → §12, `srv//Dockerfile` diff --git a/docs/BUSINESS_IDEA.md b/docs/BUSINESS_IDEA.md new file mode 100644 index 0000000..0fe6a70 --- /dev/null +++ b/docs/BUSINESS_IDEA.md @@ -0,0 +1,96 @@ +# «Контракт-чек» — AI-анализатор договоров для СНГ + +> microSaaS: загрузил договор → получил отчёт с подсветкой рисков +> по чек-листу юриста, адаптированному под ГК РФ / ГК РБ. + +--- + +## Суть одной строкой + +Загружаешь договор (PDF / DOCX / скан) → получаешь отчёт с подсветкой +рисков по чек-листу юриста, адаптированному под ГК РФ / РБ. + +## Почему это microSaaS и почему мне + +- **Одна фича** (найти риски в договоре), ничего лишнего. +- **Боль реальная и ежедневная**: фрилансер / ИП / малый бизнес подписывают + договоры, не будучи юристами, и потом ловят штрафы 200%, чужую + подсудность, одностороннее изменение цены. +- **Стек**: FastAPI + парсинг документов (pymupdf) + + Postgres / Redis + LLM. React для веба. +- **AI commoditized сложную часть** (юр. NLP), но глобальные игроки + (LegalGPT и т.п.) сидят на common law — СНГ-право это наша вотчина. +- **ЦА сидит в Telegram-каналах** (юристы, фрилансеры, ИП) — дистрибуция + копеечная, без рекламного бюджета. + +## Архитектура (ленивая, до $10k MRR) + +| Слой | Технология | +|-------------|-------------------------------------------------------------------| +| Backend | FastAPI + SQLAlchemy + Postgres + Redis | +| Workers | arq (Redis-очередь) для OCR / LLM задач | +| LLM | Ollama Cloud — hosted open-модели (напр. qwen2.5:14b). Подписка $20/мес + metered overage. Fallback: лёгкая модель при 429/квоте; план Б — GigaChat/YandexGPT (РФ) | +| OCR | pymupdf для текстовых PDF. Сканы → Tesseract (локально, rus+eng); Yandex Vision — опция улучшения качества | +| Storage | Selectel Object Storage (S3-совместимый, РФ-локация под 152-ФЗ) | +| Frontend | v1 — Telegram-бот как MVP; v2 — React-веб для B2B / API клиентов | +| Payments | ЮKassa (подписки, карты СНГ) + CloudPayments | +| Deploy | один VPS (Hetzner EU — стабильный доступ к Ollama Cloud), Docker Compose. K8s — не нужен | + +## Что реализуем в v1 (режем жёстко) + +- Приём одного документа за раз (бизнес-бот / веб-форма). +- Отчёт в markdown / PDF: список пунктов с уровнем риска + (высокий / средний), цитатой из оригинала и номером пункта + (чтобы проверить одним кликом). +- Чек-лист ~10 пунктов: штрафы / неустойки, подсудность, сроки оплаты, + IP-права, одностороннее изменение условий, гарантии, форс-мажор, НДС, + ответственность, расторжение. +- Pay-per-doc. Без подписок, без команд, без SSO. + +**Skipped**: multi-tenant orgs, API, шаблоны договоров, e-sign, +команду юристов. +**Add when**: появится 50+ платящих и запрос на подписку / командный доступ. + +## Цены + +| Тариф | Цена | Лимит | +|------------------|-------------|------------------------| +| Pay-per-doc | 199 ₽ / док | разовая | +| Подписка solo | 1 490 ₽/мес | 10 док-ов | +| Подписка team | 3 990 ₽/мес | 30 док-ов | +| API / B2B | 9 900 ₽/мес | 100 док-ов + REST API | + +### Юнит-экономика (прикидка, Ollama Cloud) + +- Опекс фиксированный до квоты: VPS Hetzner ~4 €/мес + Ollama Cloud + Pro $20/мес + Selectel S3 (копейки). Итого ~$25–30/мес на старте. +- Per-doc cost: при росте объёма — metered overage Ollama Cloud (по + токенам модели). Реальную себестоимость на документ **измеряем на + этапе 0** (квота/токены на договор); цель — заметно ниже 199 ₽. +- Маржа отлична на малом объёме (фиксированный cost); сжимается при + overage и упирается в конкарренси Pro = 3 одновременных анализа. +- 200 платящих × активность могут не прожаться на одном Pro без очередей + → Enterprise-тариф или свой GPU-бокс в РФ по достижении ~50–100 платящих. +- Цель: **200 платящих × ~1 490 ₽ ≈ 300k ₽/мес MRR** за 6–9 мес через + посев в Telegram-каналах юристов / фрилансеров + SEO «анализ договора онлайн». + +## Риски (честно) + +1. **«AI даёт юр. совет»** → позиционировать как чек-лист / подсветку, + НЕ консультацию. Disclaimer везде: «не заменяет юриста, это первичный + скрининг». +2. **LLM галлюцинирует пункты** (open-модели 14B — сильнее, чем GPT-4o) → + ВСЕГДА цитата + номер пункта из оригинала, кликабельная, + серверная + валидация JSON / repair-loop. Юзер сам сверяет за 5 секунд. +3. **152-ФЗ (ПДн)**: договоры могут содержать ПДн. Хранение (S3, БД) — + в РФ (Selectel). **НО Ollama Cloud инферисит в США** → текст договора + пересекает границу = трансграничная передача ПДн. Митигация: + обезличивание ПДн перед отправкой в LLM + disclaimer + согласие, + либо план Б — РФ-LLM (GigaChat/YandexGPT) или свой GPU-бокс в РФ. + НЕ позиционировать как «данные не покидают РФ». + +## Почему не раздавят + +- **СНГ-право + судебная практика РФ/РБ** как калибровка чек-листа — + глобалам неинтересно, слишком узкий рынок для них и в самый раз для соло. +- **Distribution через Telegram-сообщества** — там ЦА, там и продукт (бот). diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..5e7b7a9 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,546 @@ +# Руководство по развёртыванию «Контракт-чек» + +> Целевая аудитория: DevOps / разработчик, поднимающий стек на одном VPS или локально. +> Предполагается: Docker + Docker Compose установлены, есть SSH-доступ к VPS. +> Полная архитектура: [`ARCHITECTURE.md`](ARCHITECTURE.md). + +--- + +## Содержание + +1. [Требования](#1-требования) +2. [Быстрый старт локально](#2-быстрый-старт-локально) +3. [Конфигурация через `.env`](#3-конфигурация-через-env) +4. [Docker Compose — полный стек](#4-docker-compose--полный-стек) +5. [Первичная инициализация](#5-первичная-инициализация) +6. [Проверка работоспособности](#6-проверка-работоспособности) +7. [Telegram-бот: первый запуск](#7-telegram-бот-первый-запуск) +8. [B2B API: первый API-ключ](#8-b2b-api-первый-api-ключ) +9. [Резервное копирование](#9-резервное-копирование) +10. [Обновление (zero-downtime-ish)](#10-обновление) +11. [Масштабирование](#11-масштабирование) +12. [Troubleshooting](#12-troubleshooting) + +--- + +## 1. Требования + +| Компонент | Минимум | Рекомендуемое | +|---|---|---| +| CPU | 2 vCPU | 4 vCPU (worker-extract CPU-bound) | +| RAM | 2 GB | 4 GB | +| Disk | 20 GB SSD | 40 GB SSD (MinIO + WAL-archive) | +| OS | Ubuntu 22.04+ / Debian 12+ | Ubuntu 24.04 LTS | +| Docker | 24.0+ | 25.0+ | +| Docker Compose | v2 | v2 | +| Интернет | нужен для Ollama Cloud | — | + +> **Важно:** Ollama Cloud инферит в США. Если 152-ФЗ data-residency критичен — +> рассмотрите self-hosted Ollama (GPU) или GigaChat-адаптер (`core/llm/factory.py`). + +--- + +## 2. Быстрый старт локально + +### 2.1 Клонирование и env + +```bash +git clone contract-check +cd contract-check +cp .env.example .env +# Отредактируйте .env — минимум: OLLAMA_HOST, OLLAMA_API_KEY, BOT_TOKEN, +# TELEGRAM_BOT_TOKEN, JWT_SECRET +``` + +### 2.2 Инфраструктура (только Postgres + Redis + RabbitMQ + MinIO) + +```bash +docker compose up -d +# Ждём healthy: +docker compose ps +# Ожидаемый результат: postgres, redis, rabbitmq, minio, minio-init — Up (healthy) +``` + +Порты на хосте (смещены, чтобы не конфликтовать): +- Postgres: `15432` +- Redis: `17379` +- RabbitMQ AMQP: `5672`, Management UI: `http://localhost:15672` +- MinIO S3 API: `9000`, Console: `http://localhost:9001` + +### 2.3 Миграции + +```bash +# Локально (нужен uv + dev-группа): +uv sync --group dev +uv run alembic upgrade head + +# Или через временный api-контейнер: +docker compose --profile services run --rm api alembic upgrade head +``` + +### 2.4 Полный стек (api + workers + bot) + +```bash +docker compose --profile services up -d --build +# Ждём healthy: +docker compose --profile services ps +``` + +--- + +## 3. Конфигурация через `.env` + +Все секреты — в `.env` (не коммитить!). Ключевые переменные: + +```bash +# --- LLM (обязательно) --- +OLLAMA_HOST=https://api.ollama.com +OLLAMA_API_KEY=sk-xxxxxxxx +OLLAMA_MODEL=qwen2.5:14b +OLLAMA_FALLBACK_MODEL=qwen2.5:7b + +# --- Telegram + auth (обязательно) --- +BOT_TOKEN=123456789:ABCDEF... # для aiogram бота +TELEGRAM_BOT_TOKEN=$BOT_TOKEN # тот же токен; API использует для проверки Login Widget / Mini App +BOT_SERVICE_TOKEN=bot-prod-secret-xxx # см. §5.3 +JWT_SECRET=$(openssl rand -hex 32) # HS256 secret для подписи JWT + +# --- Postgres (можно оставить defaults для dev) --- +POSTGRES_USER=contract_check +POSTGRES_PASSWORD=changeme-strong-password +POSTGRES_DB=contract_check + +# --- RabbitMQ --- +RABBITMQ_USER=contract_check +RABBITMQ_PASS=changeme-strong-password + +# --- MinIO --- +S3_ACCESS_KEY=contract_check +S3_SECRET_KEY=changeme-strong-password +S3_BUCKET=contract-check-docs + +# --- Billing --- +REFUND_POLICY=all # или infra_only + +# --- Observability (опционально) --- +SENTRY_DSN=https://...@sentry.io/... +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 +``` + +> Полный список: см. `.env.example` и `ARCHITECTURE.md §11`. + +--- + +## 4. Docker Compose — полный стек + +### 4.1 Профили + +| Профиль | Что поднимает | +|---|---| +| *(default)* | `postgres`, `redis`, `rabbitmq`, `minio`, `minio-init` | +| `services` | `api`, `worker-extract`, `worker-analyze`, `bot` | +| `obs` *(планируется)* | `prometheus`, `grafana`, `otel-collector`, `tempo` | +| `edge` *(планируется)* | `nginx`, `certbot` | + +```bash +# Инфра: +docker compose up -d + +# + сервисы (сборка + запуск): +docker compose --profile services up -d --build + +# + observability + edge (когда будут готовы конфиги в deploy/): +# docker compose --profile services --profile obs --profile edge up -d --build +``` + +### 4.2 depends_on и healthchecks + +`api` ждёт `postgres`, `rabbitmq`, `minio-init` (healthy / completed). +`bot` ждёт только `api` (healthy) — не инфра напрямую, соблюдая hexagonal boundary. +Workers ждут `postgres`, `rabbitmq`, `minio-init`. + +--- + +## 5. Первичная инициализация + +### 5.1 Миграции + +```bash +# Способ A: локально (если uv установлен) +uv run alembic upgrade head + +# Способ B: через api-контейнер (если uv недоступен на хосте) +docker compose --profile services run --rm api alembic upgrade head +``` + +Проверка: +```bash +docker compose exec postgres psql -U contract_check -d contract_check -c "\dt" +# Должны быть: api_key_requests, api_keys, documents, invoices, jobs, reports, service_tokens, users +``` + +### 5.2 Seed-token для адаптеров (bot / web / cli) + +Бот аутентифицируется в api через `Authorization: Bearer `. +Этот токен нужно положить в `service_tokens`. + +```bash +# Генерируем секрет +BOT_SERVICE_TOKEN=$(openssl rand -hex 32) +echo "BOT_SERVICE_TOKEN=$BOT_SERVICE_TOKEN" >> .env + +# Записываем hash в БД через временный api-контейнер +docker compose --profile services run --rm api \ + python -c " +import asyncio, os, sys +os.chdir('/app') +sys.path.insert(0, 'src') +from contract_check.core.config import get_settings +from contract_check.core.tokens import hash_token +from contract_check.core.db.session import create_session_factory +from sqlalchemy import text +async def seed(): + factory = create_session_factory() + async with factory() as s: + h = hash_token('$BOT_SERVICE_TOKEN') + await s.execute(text('INSERT INTO service_tokens (name, token_hash, adapter) VALUES (:n, :h, :a) ON CONFLICT (name) DO UPDATE SET token_hash = EXCLUDED.token_hash'), {'n': 'bot-prod', 'h': h, 'a': 'bot'}) + await s.commit() + print('seeded bot-prod') +asyncio.run(seed()) +" +``` + +> В production используйте `python -m contract_check.api seed-token` CLI (если реализовано). + +### 5.3 Перезапуск бота с новым токеном + +```bash +docker compose --profile services restart bot +# Проверка логов: +docker compose --profile services logs -f bot +``` + +--- + +## 6. Проверка работоспособности + +### 6.1 Health endpoints + +```bash +curl http://localhost:8000/healthz # liveness — 200 +curl http://localhost:8000/readyz # readiness — 200 (PG + Rabbit + MinIO) +curl http://localhost:8000/metrics # Prometheus exposition +``` + +### 6.2 RabbitMQ Management UI + +Откройте `http://localhost:15672` (guest/guest или credentials из `.env`). +Проверьте: +- Exchange `contracts.x` (direct) +- Queues: `extract.q`, `analyze.q` (quorum), `extract.retry.q`, `analyze.retry.q`, DLQ +- Connections/consumers от worker-ов + +### 6.3 MinIO Console + +`http://localhost:9001` (root user = `S3_ACCESS_KEY` / `S3_SECRET_KEY`). +Бакет `contract-check-docs` должен появиться после `minio-init`. + +### 6.4 End-to-end smoke-тест (через curl) + +```bash +# 1. Сначала нужен JWT пользователя. В dev можно обменять telegram_id через +# service-token endpoint /auth/telegram/bot (в production этим занимается бот): +curl -X POST -H "Authorization: Bearer $BOT_SERVICE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"telegram_id": 123456}' \ + http://localhost:8000/api/v1/auth/telegram/bot +# → {"access_token":"eyJ...", "user_id":"...", "telegram_id":123456, ...} + +USER_JWT="eyJ..." # скопируйте access_token + +# 2. Проверить /me +curl -H "Authorization: Bearer $USER_JWT" http://localhost:8000/api/v1/me +# → {"telegram_id":123456, "credits_left":0} + +# 3. Загрузить документ (нужен credit — пока 0, но проверим путь) +curl -X POST -H "Authorization: Bearer $USER_JWT" \ + -F "file=@contract.pdf" http://localhost:8000/api/v1/documents +# → 402 Payment Required (нормально — нет кредитов) + +# 4. Добавить кредит вручную (dev): +docker compose exec postgres psql -U contract_check -d contract_check \ + -c "UPDATE users SET credits_left = 10 WHERE telegram_id = 123456;" +``` + +--- + +## 7. Telegram-бот: первый запуск + +### 7.1 Создание бота в BotFather + +1. Напишите `@BotFather` → `/newbot` +2. Скопируйте токен (`BOT_TOKEN`) в `.env` +3. Установите webhook (опционально, polling работает по умолчанию): + ```bash + curl -F "url=https://your-domain.com/webhook" \ + https://api.telegram.org/bot$BOT_TOKEN/setWebhook + ``` + +### 7.2 Запуск + +```bash +docker compose --profile services up -d bot +docker compose --profile services logs -f bot +``` + +Ожидаемый вывод при `/start`: +``` +Привет! Я «Контракт-чек» — первичный скрининг рисков в договорах... +Осталось проверок: 0. +``` + +### 7.3 Добавление кредитов пользователю + +```bash +# Найти telegram_id пользователя (из логов бота или таблицы users) +docker compose exec postgres psql -U contract_check -d contract_check \ + -c "UPDATE users SET credits_left = credits_left + 5 WHERE telegram_id = ;" +``` + +--- + +## 8. B2B API: первый API-ключ + +### 8.1 Создание ключа (через user JWT) + +Управление B2B-ключами теперь требует пользовательский JWT. Получите JWT через +`/auth/telegram/bot` (как в §6.4) и выполните: + +```bash +curl -X POST -H "Authorization: Bearer $USER_JWT" \ + -H "Content-Type: application/json" \ + -d '{"name": "integration-test", "rate_limit_rps": 3, "monthly_quota": 100}' \ + http://localhost:8000/api/v1/b2b/keys +# → {"api_key": "cc_abc123...", "id": "...", ...} +# Сохраните api_key — он показывается ТОЛЬКО ОДИН РАЗ +``` + +### 8.2 Анализ документа через B2B + +```bash +API_KEY="cc_abc123..." +curl -X POST -H "X-API-Key: $API_KEY" \ + -F "file=@contract.pdf" \ + http://localhost:8000/api/v1/analyze +# → 202 {"document_id": "...", "correlation_id": "..."} +``` + +### 8.3 Проверка usage + +```bash +curl -H "X-API-Key: $API_KEY" http://localhost:8000/api/v1/b2b/usage +``` + +--- + +## 9. Резервное копирование + +### 9.1 Postgres + +```bash +# Ручной backup +docker compose exec postgres pg_dump -U contract_check -d contract_check \ + > backup_$(date +%Y%m%d_%H%M%S).sql + +# Автоматический backup (cron на хосте) +# 0 2 * * * cd /opt/contract-check && docker compose exec -T postgres pg_dump -U contract_check -d contract_check | gzip > backups/pg_$(date +\%Y\%m\%d).sql.gz +``` + +### 9.2 WAL-архивы (PITR-ready) + +Postgres настроен с `wal_level=replica`, `archive_mode=on`. +WAL-сегменты пишутся в volume `pgwal` (mount `/walarchive`). +Для полноценного PITR настройте `pg_backrest` или репликацию (см. `ARCHITECTURE.md §10`). + +### 9.3 MinIO + +MinIO хранит raw-документы и extracted `.txt`. ILM-правило истекает объекты +через `DOC_RETENTION_DAYS` (по умолчанию 7 дней). **Отчёты живут в Postgres** — они +выживут после истечения raw-документов. Backup MinIO не обязателен для бизнес-логики. + +--- + +## 10. Обновление + +### 10.1 Rolling update (без остановки всего) + +```bash +# 1. Pull изменений +git pull origin main + +# 2. Rebuild + recreate (Compose пересоздаёт только изменённые контейнеры) +docker compose --profile services up -d --build + +# 3. Миграции (если есть новые) +docker compose --profile services run --rm api alembic upgrade head + +# 4. Проверка: +curl http://localhost:8000/readyz +``` + +### 10.2 Graceful shutdown workers + +Workers получают `SIGTERM` → завершают текущее сообщение → `SIGKILL` после grace period. +Compose `stop_grace_period` по умолчанию 10s; для долгих контрактов можно увеличить. + +--- + +## 11. Масштабирование + +### 11.1 На одном VPS (вертикальное) + +- Увеличьте `MQ_PREFETCH_EXTRACT` до числа CPU +- Увеличьте `MQ_PREFETCH_ANALYZE` до конкарренси Ollama (3 на Pro, 10 на Max) +- Масштабируйте RAM под размер контрактов + +### 11.2 Горизонтальное (несколько worker-ов) + +```yaml +# docker-compose.override.yml +services: + worker-extract: + deploy: + replicas: 2 + worker-analyze: + deploy: + replicas: 2 +``` + +```bash +docker compose --profile services up -d --scale worker-extract=2 --scale worker-analyze=2 +``` + +> Workers stateless — горизонтальное масштабирование бесплатно. Единственное ограничение: +> конкарренси Ollama Cloud (3 на Pro). Не масштабируйте `worker-analyze` выше, +> чем позволяет квота провайдера — иначе получите 429. + +### 11.3 Многонодовая HA + +См. `ARCHITECTURE.md §10`. Путь к HA — только инфраструктурный (Compose → кластер +RabbitMQ 3 nodes + Patroni Postgres + distributed MinIO). **Код `core/` не меняется.** + +--- + +## 12. Troubleshooting + +### 12.1 `docker compose up` зависает — сервисы не стартуют + +**Причина:** `depends_on: condition: service_healthy` — кто-то не прошёл healthcheck. + +```bash +# Диагностика: +docker compose ps +docker compose logs + +# Частые причины: +# - Postgres ещё не готов: подождите 15-20s после первого запуска +# - minio-init не выполнился: проверьте docker compose logs minio-init +# - RabbitMQ не отвечает: docker compose logs rabbitmq +``` + +### 12.2 `POST /documents` → 402 Payment Required + +Пользователю не хватает `credits_left`. В dev добавьте вручную: +```bash +docker compose exec postgres psql -U contract_check -d contract_check \ + -c "UPDATE users SET credits_left = credits_left + 1 WHERE telegram_id = ;" +``` + +### 12.3 Worker-extract падает с `ExtractionError` + +Документ — скан/PDF без текстового слоя. Worker должен автоматически перейти к OCR. +Если и OCR падает — `documents.status=failed`, credit refunded (если refundable). +Проверьте: +```bash +docker compose --profile services logs worker-extract +# Или в Postgres: +docker compose exec postgres psql -U contract_check -d contract_check \ + -c "SELECT id, status, stage, last_failure_class FROM documents ORDER BY created_at DESC LIMIT 5;" +``` + +### 12.4 Worker-analyze: постоянные 429 / `llm_quota` + +- Превышена квота Ollama Cloud. Проверьте usage в dashboard ollama.com. +- Уменьшите `MQ_PREFETCH_ANALYZE` или перейдите на Enterprise тариф. +- Проверьте fallback-model (`OLLAMA_FALLBACK_MODEL`) — он должен сработать на 429. + +### 12.5 Bot: `Unauthorized` / 401 + +- `BOT_SERVICE_TOKEN` не совпадает с `service_tokens.token_hash` в БД. +- Бот не смог получить user JWT через `/api/v1/auth/telegram/bot` (проверьте логи бота и api). +- `TELEGRAM_BOT_TOKEN` / `JWT_SECRET` не заданы в `.env` для сервиса `api`. +- Пересоздайте токен через §5.2. + +### 12.6 Бот не отвечает + +```bash +# Проверка логов +docker compose --profile services logs -f bot + +# Проверка polling: +# Бот использует polling по умолчанию (aiogram). Если webhook установлен — +# убедитесь, что Nginx проксирует /webhook к api:8000. +``` + +### 12.7 MinIO: файлы не видны / bucket не создан + +```bash +# Ручной init (если minio-init не отработал) +docker compose run --rm minio-init +# Или проверьте через mc: +docker compose run --rm minio-init mc ls local/ +``` + +### 12.8 Миграции: `alembic` не видит таблицы / revision conflict + +```bash +# Проверка текущей HEAD: +docker compose --profile services run --rm api alembic current + +# Принудительный upgrade (осторожно — только dev!): +docker compose --profile services run --rm api alembic stamp head +``` + +### 12.9 Полный сброс (dev only!) + +```bash +# Удалить ВСЕ данные (тома + контейнеры): +docker compose --profile services down -v +docker compose down -v +# Затем пересоздать с нуля: §2 + §5 +``` + +--- + +## Чек-лист перед production + +- [ ] `.env` заполнен, `.env.example` не содержит реальных секретов +- [ ] `POSTGRES_PASSWORD`, `RABBITMQ_PASS`, `S3_SECRET_KEY` — strong random +- [ ] `.env` заполнен (включая `TELEGRAM_BOT_TOKEN`, `JWT_SECRET`) +- [ ] `BOT_SERVICE_TOKEN` засеян в `service_tokens` +- [ ] Миграции накатаны (`alembic upgrade head`) +- [ ] `docker compose --profile services ps` показывает все healthy +- [ ] `/healthz` и `/readyz` отвечают 200 +- [ ] Telegram-бот отвечает на `/start` +- [ ] Тестовый PDF проходит pipeline: upload → extract → analyze → report +- [ ] B2B-ключ создаётся через `/api/v1/b2b/keys` с user JWT +- [ ] Backup cron настроен +- [ ] Firewall: открыты только 443 (nginx), 22 (ssh), 15672 (RabbitMQ mgmt, restrict IP) + +--- + +## Ссылки + +- [ARCHITECTURE.md](ARCHITECTURE.md) — полная архитектура, схемы БД, RabbitMQ topology, конфиг +- [TICKETS.md](TICKETS.md) — текущие статусы задач +- [README.md](../README.md) — структура, quick start, API overview diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..bf8d461 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,338 @@ +# План реализации «Контракт-чек» (Ollama Cloud / hosted LLM) + +> **Примечание:** этот документ описывает исходную «ленивую» архитектуру с arq+Redis +> (очередь), Selectel S3 (хранилище) и единым Docker-образом с `MODE=api|worker|bot`. +> **Этап 0 (прототип) актуален.** Этапы 1+ superseded [`ARCHITECTURE.md`](ARCHITECTURE.md), +> где реализована production-архитектура: RabbitMQ-конвейер, MinIO, два worker-а, +> aiogram-бот, B2B API, 5 Dockerfile-ов в `srv/`, dependency-groups (PEP 735). + +> Принцип: ленивая архитектура. Каждый этап — минимальный работающий +> срез. Никаких абстракций «на потом». K8s, RabbitMQ, микросервисы — +> не нужны до $10k MRR. +> +> **LLM:** [Ollama Cloud](https://ollama.com/cloud) — hosted-инференс +> открытых моделей. Те же модели и API (`/api/chat`, `format: json`), +> что и у локального Ollama, но без своего железа: платим подписку + +> metered overage, инференс бежит у Ollama (NCP-партнёры, в основном США). + +--- + +## О выборе Ollama Cloud + +- **API:** стандартный Ollama HTTP API на cloud-эндпоинте. Авторизация — + API-ключ (bearer) из настроек аккаунта Ollama. Структурный вывод через + `format: json` (или JSON-schema) для парсимого отчёта. +- **Модели:** только cloud-enabled (см. `ollama.com/search?c=cloud`). + Рекомендация — **`qwen2.5:14b`** (сильный русский + reasoning). + Fallback — более лёгкая **`qwen2.5:7b`** при 429/квоте. + Теги БЕЗ суффикса `-instruct` (его нет в Ollama): `qwen2.5:14b`, + `llama3.1:8b`, `gemma2:9b` и т.п. +- **`format: json` ≠ соответствие схеме.** Ollama гарантирует синтаксис + JSON; соответствие вашей pydantic-схеме проверяем на клиенте + цикл + repair/retry. Не полагаемся на модель. + +## Стоимость и лимиты Ollama Cloud (актуально на момент планирования) + +| План | Цена | Конкарренси | Назначение | Статус | +|-------|-------------|-------------|-------------------------|------------------| +| Free | $0 | 1 | тесты | открыт | +| Pro | $20/мес | 3 | рабочая лошадка MVP | открыт | +| Max | $100/мес | 10 | тяжёлый поток | **приостановлен** | +| Team | $25/seat | — | команда (5 seat min) | waitlist | +| Enterprise | custom | custom | продакшн SaaS | по запросу | + +- **Usage:** rolling-лимиты — сессия 5 ч + недельный 7 дн. Pro = 50× Free. + При превышении — **metered overage** (добиваем баланс, оплата по + токенам модели). Жёсткой стены нет, но конкарренси = 3 на Pro. +- **Хостинг: в основном США** (подключаются EU/Singapore). Это **ломает + 152-ФЗ data-residency**, см. раздел «Риски». + +## Экономика проекта (честно) + +- Опекс фиксированный до квоты: VPS ~4 €/мес + Ollama Cloud Pro $20/мес + + Selectel S3 (копейки). Итого ~$25–30/мес на старте. +- Рост объёма → metered overage Ollama Cloud по токенам. Маржа на + pay-per-doc (199 ₽) остаётся, **если** один договор съедает мало + квоты. Это **обязательно измерить на этапе 0** (квота/документ). +- В отличие от локального Ollama — нет потолка по железу, но есть потолок + по конкарренси (3) и плавающий cost при overage. В отличие от GigaChat — + cost в $, не в ₽, и нет РФ-локализации данных. + +--- + +## Архитектура — hexagonal (ports & adapters) + +Ядро и адаптеры разделены. Это снимает связность «бот знает про БД/S3/LLM» и +позволяет добавлять каналы доставки (CLI, веб, B2B API) без дублирования +бизнес-логики. + +- **Ядро (application core):** + - `api` (FastAPI/uvicorn) — владеет БД, S3, кредитами, оплатой; принимает + документы, **резервирует кредит на enqueue**, ставит arq-задачи в redis, + отдаёт отчёты/профиль. Аутентификация адаптеров — общий `SERVICE_TOKEN`. + - `worker` (arq) — разбирает очередь: S3 → экстракт → (OCR) → chunker → + analyzer → Ollama Cloud → Report. Idempotency + refund при ошибке. + - Общий доменный пакет `contract_check/` (`extractor`, `chunker`, + `llm_client`, `analyzer`, `checklist`, `report_schema`, `ocr`, `storage`, + `db`, `models`, `payments`, `quota`) — используется только ядром. +- **Адаптеры (delivery = HTTP-клиенты к `api`):** + - `bot` (aiogram) — Telegram: приём документа → `POST /documents` (multipart) + → отдача отчёта. **Не трогает БД/S3/redis/LLM.** + - `cli` — пользовательский/отладочный CLI поверх api (новый, отдельный от + прототипа). + - `web` (React SPA) — этап 2, тоже HTTP-клиент к api. +- **Прототип `prototype.py` (stage 0)** — standalone, in-process LLM; артефакт + go/no-go, **не адаптер и не ядро**. + +> Следствие для планирования: **api + worker строятся на этапе 1** (боту-адаптеру +> нужен api). Этап 2 = добавление адаптера `web` + подписок (api уже есть). + +--- + +## Этап 0 — Прототип (1 выходной) + +**Цель:** доказать, что модель через Ollama Cloud реально находит риски, +и измерить задержку + **квоту на один договор** (для юнит-экономики). + +**Деливерэбл:** один файл `prototype.py` — end-to-end. + +``` +PDF/DOCX → pymupdf/python-docx → текст → +prompt с чек-листом → Ollama Cloud (format: json) → отчёт в markdown +``` + +**Что делаем:** +1. Завести аккаунт Ollama, взять API-ключ, положить в `.env`. +2. Поставить `pymupdf`, `httpx` в venv. +3. Один скрипт: читает PDF → достаёт текст → промпт с чек-листом из 10 + пунктов → дёргает Ollama Cloud → печатает отчёт. +4. Прогнать 3–5 реальных договоров (NDA, оказание услуг, поставка). +5. Зафиксировать: качество находок, задержка (сек), % битых JSON, + **сколько квоты/токенов на договор**. + +**Чего НЕ делаем:** база, веб, бот, оплату, деплой. Один скрипт локально. + +**Критерий успеха:** отчёт по реальному договору содержит хотя бы 3 +осмысленные находки с цитатами; задержка < 60 сек; квота/договор даёт +понятную маржинальность при 199 ₽. + +--- + +## Этап 1 — Telegram-бот MVP (2–3 недели) + +**Цель:** первые платящие пользователи. Бот = самый быстрый путь до ЦА. + +### 1.1 Структура проекта + +``` +contract_check/ +├── pyproject.toml # hatchling, deps (core + adapters) +├── docker-compose.yml # postgres, redis, api, worker, bot (+ profile app = prototype) +├── .env.example +├── src/contract_check/ +│ ├── main.py # диспетчер: MODE=api|worker|bot|cli +│ ├── prototype.py # stage-0 standalone (in-process LLM) — НЕ в hexagonal +│ │ # ── ЯДРО (application core): владеет БД/S3/кредитами/LLM ── +│ ├── api.py # FastAPI: /documents, /reports/{id}, /me, /healthz +│ ├── worker.py # arq analyze_document(doc_id): S3→extract→OCR→analyze→Report +│ ├── analyzer.py extractor.py chunker.py ocr.py +│ ├── llm_client.py checklist.py report_schema.py +│ ├── storage.py db.py models.py payments.py quota.py +│ │ # ── АДАПТЕРЫ (delivery): HTTP-клиенты к api, без БД/S3/LLM ── +│ ├── bot.py # aiogram: приём документа → POST /documents → отчёт +│ └── cli.py # CLI поверх api (новый, не прототип) +├── migrations/ # alembic +└── tests/ +``` + +### 1.2 База данных — 3 таблицы + +```sql +users (id, telegram_id, created_at, credits_left) +documents (id, user_id, s3_key, status, created_at) +reports (id, document_id, content_json, created_at) +``` + +`credits_left` — prepaid-кредиты (pay-per-doc). Подписок в v1 нет. +`status` хранить как `TEXT` + `CHECK`, не Postgres-ENUM (миграции проще). + +### 1.3 Поток (резервирование кредита на enqueue) + +``` +Юзер кидает PDF в бот + → бот (adapter): multipart'ом шлёт файл в api: POST /documents (+ SERVICE_TOKEN) + → api (core): get_or_create_user, проверяет credits_left > 0, РЕЗЕРВИРУЕТ (-= 1), + грузит файл в S3, создаёт Document(status=queued), ставит arq-таску → 202 + job_id + → worker (core): idempotency guard по status, достаёт из S3 → текст → (OCR если скан) + → worker: chunker при необходимости → analyzer → Ollama Cloud → отчёт + → worker: pydantic-валидация + repair-loop; пишет Report, status=done + → при ошибке: status=failed, ВОЗВРАТ кредита (+1) + → бот: опрашивает GET /reports/{id} (или push), отправляет отчёт + (с разбивкой/файлом при >4096 симв.), футер-disclaimer +``` + +### 1.4 Оплата (минимум) + +- ЮKassa: бот генерирует ссылку на оплату N ₽ → webhook пополняет + `credits_left`. +- Один тариф: 199 ₽ = 1 документ. Без подписок. + +### 1.5 Деплой + +- **Один VPS** (Hetzner CX22 ~4 €/мес или Selectel под РФ-локацию БД). +- Docker Compose: `postgres`, `redis`, `api`, `worker`, `bot`. **Без GPU.** + (Бот — адаптер к api; веб-адаптер `web` добавится на этапе 2.) +- S3 — Selectel Object Storage. +- Домен + HTTPS только для webhook ЮKassa; на старте ngrok (с оговоркой: + URL на free-ngrok меняется → лучше сразу дешёвый домен + Caddy). + +### 1.6 Критерий успеха + +10 платящих. MRR ~2 000 ₽. Отчёты не вызывают жалоб «ничего не нашёл». +Ollama Cloud Pro покрывает нагрузку без ухода в overage. + +--- + +## Этап 2 — Веб + подписки (3–4 недели) + +**Цель:** B2B-веб-интерфейс и подписки. + +### 2.1 Что добавляем + +- **`api` уже построен на этапе 1** (T-E1-015). На этапе 2 добавляем: роуты + подписок/счетов, Telegram Login auth (сессия/JWT) для веб-адаптера, и сам + веб-адаптер `web` (React SPA) — ещё один HTTP-клиент к api. +- React SPA (Vite): загрузка, история, профиль, подписка. Отдаётся статикой + позже (отдельный `web`-контейнер / Nginx). +- Auth: Telegram Login Widget (нужен публичный HTTPS-домен в BotFather) → + сессия/JWT. +- Подписки: solo (1 490 ₽), team (3 990 ₽). ЮKassa recurring. + +### 2.2 Что меняем в базе + +```sql +ALTER TABLE users ADD COLUMN plan TEXT DEFAULT 'free'; +ALTER TABLE users ADD COLUMN plan_renews_at TIMESTAMP; +CREATE TABLE invoices (id, user_id, amount, status, provider, external_id, created_at); +``` + +`credits_left` остаётся для pay-per-doc; подписка = безлимит с monthly +reset через cron-arq-таску. + +### 2.3 Архитектура — без изменений в ядре + +`api` и `worker` работают с этапа 1; на этапе 2 добавляется только адаптер +`web` (React, статика) перед Nginx → HTTPS (Let's Encrypt). Hexagonal-граница +сохранена: `web` — такой же HTTP-клиент к api, как `bot`. LLM по-прежнему +Ollama Cloud; при росте — Enterprise тариф или переход на свой GPU. + +### 2.4 Критерий успеха + +50 платящих. MRR ~30 000 ₽. Есть хотя бы один team-клиент. + +--- + +## Этап 3 — B2B API (2 недели, только если есть спрос) + +**Цель:** сторонние сервисы дёргают анализ через API. + +### 3.1 Что добавляем + +- API-ключи: таблица `api_keys`, header `X-API-Key`. +- Rate-limit: Redis (token bucket). Лимит = **конкарренси/квота Ollama + Cloud** (3 на Pro), а не ₽. +- `POST /api/v1/analyze` (multipart) → 202 + job_id → + `GET /api/v1/reports/{id}`. +- Дашборд: ключи, usage, счета. + +### 3.2 Чего НЕ делаем + +- Нет SDK, нет вебхуков (клиент поллит), нет OAuth2. +- Один тариф API: 9 900 ₽/мес за 100 запросов. + +### 3.3 Критерий успеха + +3 API-клиента. MRR +30 000 ₽. + +--- + +## Инфраструктура — сводка + +| Компонент | Этап 0 | Этап 1 | Этап 2–3 | +|----------------|------------|------------------------------|--------------------------------| +| Compute | ноутбук | 1 VPS (Docker Compose) | 1 VPS (апгрейд RAM) | +| LLM | Ollama Cloud (Free/Pro) | Ollama Cloud Pro | Ollama Cloud Pro/Enterprise | +| DB | — | Postgres (в compose) | Postgres (+ backup cron) | +| Queue | — | Redis (в compose) | Redis (тот же) | +| Object storage | — | Selectel S3 | Selectel S3 | +| OCR | — | Tesseract (локально) | Tesseract (+ Yandex Vision) | +| Payments | — | ЮKassa | ЮKassa + CloudPayments | +| Monitoring | — | Docker logs | Uptime Kuma + Sentry (free) | +| CI/CD | — | git push → ssh deploy | GitHub Actions → build → deploy| + +**K8s / RabbitMQ / Kafka / Elasticsearch / vLLM / TGI — НЕ НУЖНЫ.** Один +VPS + Ollama Cloud держит всё до заметного объёма. Свой GPU — только если +Ollama Cloud overage станет дороже self-host (отдельное решение позже). + +--- + +## Чек-лист пунктов анализа (v1) + +Содержимое `checklist.py` — один список, без БД: + +1. Неустойки / штрафы (размер, односторонний) +2. Подсудность (чужой регион) +3. Сроки оплаты (условия, просрочка) +4. IP-права (кому отходят результаты) +5. Одностороннее изменение условий +6. Гарантии и их срок +7. Форс-мажор (формулировки) +8. НДС (включён / сверх) +9. Ответственность сторон (cap, исключения) +10. Расторжение (условия, уведомление) + +--- + +## Сроки (реалистично, соло, вечера/выходные) + +| Этап | Что | Время | +|------|------------------|-----------| +| 0 | Прототип | 1 выходной| +| 1 | Telegram-бот MVP | 2–3 недели| +| 2 | Веб + подписки | 3–4 недели| +| 3 | B2B API | 2 недели | + +**До первого платящего (0+1): ~3–4 недели.** Заложить ~1–2 дня на подбор +cloud-модели и тюнинг промпта (локальные/open модели капризнее GPT-4o). + +--- + +## Риски Ollama Cloud (честно) + +1. **152-ФЗ / data-residency.** Контракт улетает в Ollama Cloud (США). + Это тот же класс риска, что и GPT-4o. Митигация: disclaimer, + обезличивание ПДн перед отправкой, либо при необходимости — + отказ от Ollama Cloud в пользу РФ-LLM (GigaChat/YandexGPT) или своего + GPU-бокса. **Не позиционировать продукт как «данные не покидают РФ».** +2. **Квота/конкарренси.** Pro = 3 одновременных модели + rolling usage. + Бурст платящих юзеров упрётся в очередь/429. Трекать usage, + алертить у лимита, при росте — overage-баланс или Enterprise. +3. **Качество open-моделей.** 14B галлюцинирует/пересказывает цитаты + сильнее GPT-4o/GigaChat. Всегда: цитата + номер пункта + ремонт-цикл + валидации JSON. Disclaimer «не заменяет юриста» — в каждый отчёт. +4. **Зависимость от одного провайдера.** Один аккаунт Ollama (нельзя + несколько). Иметь готовый план Б: GigaChat/YandexGPT-фолбэк или свой + GPU при блокировке/превышении квоты. + +--- + +## Что сознательно отложено (YAGNI) + +- Multi-tenant / организации / роли — пока все юзеры = solo. +- Шаблоны договоров (генерация) — другой продукт. +- ЭЦП / Госуслуги — чужой регуляторный ад. +- Команда юристов (human-in-the-loop) — только если попросят. +- Mobile app — веб + бот закрывают 100%. +- White-label — один продукт, один бренд. +- Свой GPU / vLLM / TGI / RAG-над-векторной-базой — пока Ollama Cloud + дешевле; пересмотрим при выходе overage в минус. diff --git a/docs/TICKETS.md b/docs/TICKETS.md new file mode 100644 index 0000000..72c5a31 --- /dev/null +++ b/docs/TICKETS.md @@ -0,0 +1,216 @@ +# Тикеты реализации «Контракт-чек» (production refactor) + +> **Актуальная архитектура:** `ARCHITECTURE.md` (supersedes `IMPLEMENTATION_PLAN.md` для этапов 1+). +> Состояние кода на момент синхронизации: реализованы `api/` (вкл. B2B-роуты), `core/`, +> `worker_extract/`, `worker_analyze/`, `bot/`, `prototype/`; все 5 Dockerfile-ов в `srv/`; +> `docker-compose.yml` полностью разводит профиль `services`; миграции `0001_initial` + `0002_api_keys`; +> unit-тесты зелёные. DoD: `ruff check .`, `mypy src`, `pytest` зелёные; `.env.example` актуален. +> +> Статусы: `todo` / `in_progress` / `done` / `blocked`. + +--- + +## Аудит реализации (текущее состояние) + +> Проверено: код собирается (`pyproject.toml` + `uv.lock`), миграции накатываются, +> `api/` стартует, unit-тесты проходят (58 тестов зелёные). + +| Компонент | Статус | Доказательство / пробел | +|-----------|--------|---------------------------| +| Stage 0 prototype | done | `src/contract_check/prototype/` работает; `tests/unit/test_checklist_report.py`, `test_chunker.py`, `test_extractor.py` зелёные. | +| `core/` — общий домен | done | `db/`, `mq/`, `s3/`, `llm/`, `analysis/`, `credits.py`, `tokens.py`, `api_keys.py`, `rate_limit.py`, `redis_client.py`, `config.py`, `logging.py`, `metrics.py`, `telemetry.py`, `sentry.py`. | +| Миграции / БД | done | `0001_initial.py` (6 таблиц) + `0002_api_keys.py` (`api_keys`, `api_key_requests`). | +| `api/` — FastAPI ядро | done | `POST /api/v1/documents`, `GET /api/v1/reports/{id}`, `GET /api/v1/documents/{id}`, `GET /api/v1/me`, `/healthz`, `/readyz`, `/metrics`; user JWT auth via `/api/v1/auth/telegram/*`; reserve-on-enqueue (`services.py`). | +| `worker_extract/` | done | `consumer.py`/`handler.py`/`extract_document.py`/`__main__.py`: consume `DocumentUploaded` → MinIO dl → extract/OCR → `.txt` upload → publish `DocumentExtracted`; failure-class + refund. | +| `worker_analyze/` | done | `consumer.py`/`handler.py`/`__main__.py`: consume `DocumentExtracted` → LLM → Report → `status=done`; refund-on-DLQ по политике. | +| `bot/` — Telegram adapter | done | `client.py`/`config.py`/`handlers.py`/`__main__.py`: `/start`, upload→`POST /documents`, poll→deliver; граница импортов проверяется `tests/unit/test_bot_boundary.py`. | +| Dockerfile-ы | done | `srv/{api,worker-extract,worker-analyze,bot,prototype}/Dockerfile` — все 5 (deps-группы PEP 735 заточены на сервис). | +| Docker Compose | done | Инфра (default) + профиль `services` (api, worker-extract, worker-analyze, bot) с `depends_on: service_healthy`. Профили `obs`/`edge` — позже. | +| Observability / edge | todo | Пром/Grafana/Tempo/OTel-collector/Nginx/certbot — не развёрнуты (нет `deploy/`). | +| Stage 2 — веб + подписки | todo | React SPA, Telegram Login, recurring ЮKassa — не начаты. | +| Stage 3 — B2B API | done | `api/routes/b2b.py`, `core/api_keys.py`, `core/rate_limit.py`, `core/redis_client.py`, миграция `0002_api_keys.py`, `tests/integration/test_b2b_api.py`, `tests/unit/test_rate_limit.py`; `X-API-Key` auth + token-bucket rate-limit. | + +--- + +## Этап 0 — Прототип (go/no-go) + +### T-E0-001 — Аккаунт Ollama Cloud + каркас прототипа +**Статус:** done · **Оценка:** S + +`.env.example` содержит `OLLAMA_HOST`/`OLLAMA_API_KEY`/`OLLAMA_MODEL=qwen2.5:14b`/`OLLAMA_FALLBACK_MODEL=qwen2.5:7b`. +`python -m contract_check --help` работает. Теги без `-instruct`. + +### T-E0-002 — Экстрактор текста (PDF/DOCX) +**Статус:** done · **Оценка:** S + +`src/contract_check/core/analysis/extractor.py`; покрыт `tests/unit/test_extractor.py`. + +### T-E0-003 — Чек-лист анализа (v1) +**Статус:** done · **Оценка:** S + +`src/contract_check/core/analysis/checklist.py` — 10 пунктов с `id/title/description`. + +### T-E0-004 — Ollama Cloud-клиент + repair-loop +**Статус:** done · **Оценка:** M + +`src/contract_check/core/llm/ollama_cloud.py`; мок-тесты 200/429-fallback/битый-JSON в `tests/unit/test_llm_ollama_cloud.py`. + +### T-E0-005 — Промпт + JSON-отчёт (с чанками) +**Статус:** done · **Оценка:** M + +`core/analysis/analyzer.py`, `chunker.py`, `report_schema.py`; disclaimer в markdown. + +### T-E0-006 — Замеры метрик и economics go/no-go +**Статус:** todo · **Оценка:** M + +Требует живых прогонов 3–5 договоров через Ollama Cloud + `docs/stage0-results.md`. + +--- + +## Этап 1 — Production refactor (api + core + workers + bot) + +> Архитектура: hexagonal, RabbitMQ pipeline (`extract.q` → `analyze.q`), MinIO, Postgres, Redis. +> Профили Docker Compose: default = инфра; `services` = api + workers + bot. + +### T-E1-001 — Ядро `contract_check.core` +**Статус:** done · **Оценка:** L + +Пакет `src/contract_check/core/` содержит `db/`, `mq/`, `s3/`, `llm/`, `analysis/`, `credits.py`, `tokens.py`, `config.py`, `logging.py`, `metrics.py`, `telemetry.py`, `sentry.py`. + +### T-E1-002 — Миграции БД (6 таблиц) +**Статус:** done · **Оценка:** M + +`migrations/versions/0001_initial.py`: `users`, `documents`, `reports`, `jobs`, `service_tokens`, `invoices` (stub). + +### T-E1-003 — FastAPI ядро: документы, кредиты, enqueue, отчёты +**Статус:** done · **Оценка:** L + +`src/contract_check/api/`: `POST /api/v1/documents`, `GET /api/v1/reports/{id}`, `GET /api/v1/me`, `/healthz`, `/metrics`. +Reserve-on-enqueue (`core/credits.py`) и user JWT auth (`core/auth.py`, `api/routes/auth.py`). + +### T-E1-004 — Worker-extract (CPU: pymupdf + tesseract) +**Статус:** done · **Оценка:** M · **Зависимости:** T-E1-001, T-E1-002 + +`src/contract_check/worker_extract/` (`consumer.py`/`handler.py`/`extract_document.py`/`__main__.py`): +consume `DocumentUploaded` из `extract.q` → скачать blob из MinIO → +`extractor` + `ocr.ocr_pdf()` → загрузить `.txt` → publish `DocumentExtracted` в `analyze.q`. +Failure-classification + refund-on-DLQ. Образ `srv/worker-extract/Dockerfile`. + +### T-E1-005 — Worker-analyze (I/O: LLM provider) +**Статус:** done · **Оценка:** M · **Зависимости:** T-E1-001, T-E1-002, T-E1-004 + +`src/contract_check/worker_analyze/` (`consumer.py`/`handler.py`/`__main__.py`): +consume `DocumentExtracted` из `analyze.q` → скачать `.txt` → chunk → LLM (через `core/llm` port) +→ validate/repair → сохранить `Report`, `status=done`; refund при terminal failure (`core/credits.py`). +Образ `srv/worker-analyze/Dockerfile`. + +### T-E1-006 — Telegram-бот (aiogram 3) — HTTP-адаптер +**Статус:** done · **Оценка:** M · **Зависимости:** T-E1-003 + +`src/contract_check/bot/` (`client.py`/`config.py`/`handlers.py`/`__main__.py`): +`/start`, приём PDF/DOCX → `POST /api/v1/documents`, poll `GET /api/v1/reports/{id}` +→ отправка отчёта (разбивка/файл >4096 симв.), реакция на 402/400/202. +Образ `srv/bot/Dockerfile`. **Не импортирует** `core.db`/`core.s3`/`core.llm`/`core.mq`/`core.credits` +(проверяется `tests/unit/test_bot_boundary.py`). + +### T-E1-007 — Docker Compose: сервисы и профили +**Статус:** done · **Оценка:** M · **Зависимости:** T-E1-004, T-E1-005, T-E1-006 + +`docker-compose.yml`: default-профиль = инфра (`postgres`, `redis`, `rabbitmq`, `minio`, `minio-init`); +профиль `services` = `api`, `worker-extract`, `worker-analyze`, `bot` с `depends_on: service_healthy`. +Все 5 Dockerfile-ов в `srv/`. Профили `obs`/`edge` — позже (T-E1-009/T-E1-010). + +### T-E1-008 — Оплата (ЮKassa) и пополнение кредитов +**Статус:** todo · **Оценка:** M · **Зависимости:** T-E1-003, T-E1-006 + +Роут для создания платежа 199 ₽ = 1 документ; webhook `succeeded` → `credits_left += 1`. + +### T-E1-009 — Деплой + edge (Nginx/certbot) +**Статус:** todo · **Оценка:** M · **Зависимости:** T-E1-007 + +`deploy/nginx/nginx.conf`, `deploy/nginx/certbot-init.sh`, `DEPLOY.md`, `deploy.sh`. + +### T-E1-010 — Мониторинг (Prometheus/Grafana/Tempo/Sentry) +**Статус:** todo · **Оценка:** M · **Зависимости:** T-E1-007 + +`deploy/observability/` + compose профиль `obs`. Дашборды: queue depth, job latency, LLM tokens, credits. + +--- + +## Этап 2 — Веб + подписки + +### T-E2-001 — Миграция БД: планы и счета +**Статус:** todo · **Оценка:** M · **Зависимости:** T-E1-002 + +`users.plan` (`free|solo|team`), `users.plan_renews_at`. Таблица `invoices` уже существует (stub); наполнить логикой. + +### T-E2-002 — Telegram Login Widget auth +**Статус:** done · **Оценка:** M · **Зависимости:** T-E1-003 + +Реализовано в `core/auth.py` + `api/routes/auth.py`: `/api/v1/auth/telegram/web` и `/api/v1/auth/telegram/miniapp` проверяют HMAC-подпись Telegram и выдают тот же user JWT, что и бот. + +### T-E2-003 — React SPA (Vite) +**Статус:** todo · **Оценка:** L · **Зависимости:** T-E2-002 + +Загрузка, история, профиль, подписка. Сборка в статику; `api` отдаёт `index.html` на `/`. + +### T-E2-004 — Подписки ЮKassa recurring +**Статус:** todo · **Оценка:** M · **Зависимости:** T-E2-001, T-E2-003 + +Тарифы solo (1 490 ₽), team (3 990 ₽). Ежемесячное продление/reset. + +--- + +## Этап 3 — B2B API + +> **Примечание:** в текущей архитектуре B2B API строится **не после веба**, а поверх уже готового `api` и существующей очереди. +> Зависимость от T-E2-002 снята. + +### T-E3-001 — Миграция БД: `api_keys` и `api_key_requests` +**Статус:** done · **Оценка:** M · **Зависимости:** T-E1-002 + +`migrations/versions/0002_api_keys.py`: таблицы +- `api_keys(id, user_id FK, name, key_hash, rate_limit_rps, monthly_quota, monthly_used, resets_at, revoked, created_at, last_used_at)` +- `api_key_requests(id, api_key_id FK, document_id FK, created_at)` +Накатывается/откатывается чисто; SQLAlchemy-модели в `core/db/models.py`. + +### T-E3-002 — Redis-клиент и token-bucket rate limiter +**Статус:** done · **Оценка:** M · **Зависимости:** T-E3-001 + +`core/redis_client.py` (async Redis из `redis_url`), `core/rate_limit.py` (token bucket per `api_key_id`, +с `MemoryRateLimiter`-фолбэком для unit-тестов без Redis). Лимит по умолчанию = `B2B_DEFAULT_RATE_LIMIT_RPS` (3), +переопределяется `api_keys.rate_limit_rps`. Покрыт `tests/unit/test_rate_limit.py`; 429 при превышении. + +### T-E3-003 — `X-API-Key` auth dependency +**Статус:** done · **Оценка:** M · **Зависимости:** T-E3-001, T-E3-002 + +`api/deps.py`: `require_api_key` — проверяет `X-API-Key` по `key_hash` (`core/api_keys.py`), +отклоняет revoked, обновляет `last_used_at`, применяет rate-limit. `api/routes/b2b.py`: +`POST /api/v1/analyze`, `GET /api/v1/b2b/reports/{document_id}`, `GET /api/v1/b2b/usage`. +Покрыт `tests/integration/test_b2b_api.py` (вкл. 401/429). + +### T-E3-004 — Управление API-ключами (user JWT auth) +**Статус:** done · **Оценка:** S · **Зависимости:** T-E3-001 + +`api/routes/b2b.py` под user JWT auth: `POST /api/v1/b2b/keys` (plaintext ключ возвращается +**только один раз**), `GET /api/v1/b2b/keys`, `POST /api/v1/b2b/keys/{id}/revoke`, +`GET /api/v1/b2b/keys/{id}/usage`. Revoke мгновенно отключает аутентификацию. + +### T-E3-005 — Интеграционные тесты B2B API +**Статус:** done · **Оценка:** M · **Зависимости:** T-E3-003 + +`tests/integration/test_b2b_api.py`: upload → 202 → poll report; ветки 401/429. +Seed API key в `tests/integration/conftest.py`. + +--- + +## Сводка порядка (обновлённая) + +1. **E0** — прототип (done, кроме живых замеров T-E0-006). +2. **E1.001–007** — ядро + БД + FastAPI `api` + worker-extract + worker-analyze + bot + Docker/compose (done). +3. **E3** — B2B API (done: миграция, rate-limit, auth, роуты, тесты). +4. **E1.008** — оплата ЮKassa (todo — блокирует первых платящих). +5. **E1.009–010** — деплой/edge (Nginx/certbot) и observability (Prometheus/Grafana/Tempo/Sentry) (todo). +6. **E2** — веб + подписки (todo; после 10–50 платящих). + +**Ближайшие работы:** E1.008 (оплата), затем E1.009–010 (edge + observability), затем E2 (веб + подписки). diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..e3dcc13 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,66 @@ +"""Alembic env — async engine, target metadata from core.db.models. + +URL comes from `DATABASE_URL` (via core.config.Settings), overriding the +placeholder in alembic.ini. Importing `contract_check.core.db.models` populates +`Base.metadata`; autogenerate is used only to draft, migrations are committed +hand-written (docs/ARCHITECTURE.md §7). +""" + +from __future__ import annotations + +import asyncio +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import create_async_engine + +# ensure src/ is importable when running alembic from the repo root +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +import contract_check.core.db.models # noqa: E402,F401 — populate Base.metadata +from contract_check.core.config import get_settings # noqa: E402 +from contract_check.core.db.models import Base # noqa: E402 + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = get_settings().database_url + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = create_async_engine(get_settings().database_url, poolclass=pool.NullPool) + try: + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + finally: + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..a7dde24 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0001_initial.py b/migrations/versions/0001_initial.py new file mode 100644 index 0000000..f9df8ea --- /dev/null +++ b/migrations/versions/0001_initial.py @@ -0,0 +1,234 @@ +"""initial schema: users, documents, reports, jobs, service_tokens, invoices + +Revision ID: 0001 +Revises: +Create Date: 2026-01-01 + +Hand-written (docs/ARCHITECTURE.md §7). status/queue/adapter columns are TEXT+CHECK +(additive migrations). UUIDs default to gen_random_uuid() server-side +(built into Postgres 13+; we run pg16). +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0001" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ── users ──────────────────────────────────────────────────────────────── + op.create_table( + "users", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("telegram_id", sa.BigInteger, unique=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("credits_left", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.CheckConstraint("credits_left >= 0", name="users_credits_nonneg"), + ) + + # ── documents ──────────────────────────────────────────────────────────── + op.create_table( + "documents", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("s3_key", sa.Text, nullable=False), + sa.Column("extracted_s3_key", sa.Text), + sa.Column("filename", sa.Text, nullable=False), + sa.Column("mime", sa.Text, nullable=False), + sa.Column("bytes", sa.BigInteger, nullable=False, server_default=sa.text("0")), + sa.Column("status", sa.String, nullable=False, server_default=sa.text("'queued'")), + sa.Column("stage", sa.Text), + sa.Column("refunded", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint( + "status IN ('queued','extracting','ocr','analyzing','done','failed')", + name="documents_status_check", + ), + ) + op.create_index("documents_user_created_idx", "documents", ["user_id", sa.text("created_at DESC")]) + op.create_index("documents_status_idx", "documents", ["status"]) + + # ── reports ────────────────────────────────────────────────────────────── + op.create_table( + "reports", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "document_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("documents.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ), + sa.Column("content_json", postgresql.JSONB, nullable=False), + sa.Column("markdown", sa.Text, nullable=False), + sa.Column("model_used", sa.Text), + sa.Column("prompt_tokens", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column("eval_tokens", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column("latency_ms", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + # ── jobs ───────────────────────────────────────────────────────────────── + op.create_table( + "jobs", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "document_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("documents.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("correlation_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("queue", sa.String, nullable=False), + sa.Column("attempts", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column("max_attempts", sa.Integer, nullable=False, server_default=sa.text("5")), + sa.Column("last_failure_class", sa.Text), + sa.Column("last_error", sa.Text), + sa.Column("dlq", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("status", sa.String, nullable=False, server_default=sa.text("'pending'")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint("queue IN ('extract','analyze')", name="jobs_queue_check"), + sa.CheckConstraint( + "status IN ('pending','running','retrying','dlq','done')", + name="jobs_status_check", + ), + ) + op.create_index("jobs_correlation_idx", "jobs", ["correlation_id"]) + op.create_index("jobs_document_idx", "jobs", ["document_id"]) + + # ── service_tokens ─────────────────────────────────────────────────────── + op.create_table( + "service_tokens", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("name", sa.Text, unique=True, nullable=False), + sa.Column("token_hash", sa.Text, nullable=False), + sa.Column("adapter", sa.String, nullable=False), + sa.Column("revoked", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("last_used_at", sa.DateTime(timezone=True)), + sa.CheckConstraint( + "adapter IN ('bot','web','cli')", name="service_tokens_adapter_check" + ), + ) + + # ── invoices (STUB — no ЮKassa logic this refactor) ───────────────────── + op.create_table( + "invoices", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("amount", sa.Integer, nullable=False), + sa.Column("status", sa.String, nullable=False, server_default=sa.text("'draft'")), + sa.Column("provider", sa.Text), + sa.Column("external_id", sa.Text), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("paid_at", sa.DateTime(timezone=True)), + sa.CheckConstraint( + "status IN ('draft','pending','succeeded','cancelled','refunded')", + name="invoices_status_check", + ), + ) + op.create_index("invoices_user_idx", "invoices", ["user_id", sa.text("created_at DESC")]) + + +def downgrade() -> None: + op.drop_index("invoices_user_idx", table_name="invoices") + op.drop_table("invoices") + op.drop_table("service_tokens") + op.drop_index("jobs_document_idx", table_name="jobs") + op.drop_index("jobs_correlation_idx", table_name="jobs") + op.drop_table("jobs") + op.drop_table("reports") + op.drop_index("documents_status_idx", table_name="documents") + op.drop_index("documents_user_created_idx", table_name="documents") + op.drop_table("documents") + op.drop_table("users") diff --git a/migrations/versions/0002_api_keys.py b/migrations/versions/0002_api_keys.py new file mode 100644 index 0000000..f0035aa --- /dev/null +++ b/migrations/versions/0002_api_keys.py @@ -0,0 +1,111 @@ +"""api_keys + api_key_requests tables for B2B API (Stage 3). + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-08-07 + +Hand-written (docs/ARCHITECTURE.md §7 + docs/TICKETS.md T-E3-001). status/rate columns are +TEXT+CHECK / INTEGER so later migrations remain additive. +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0002" +down_revision: str | None = "0001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ── api_keys ───────────────────────────────────────────────────────────── + op.create_table( + "api_keys", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.Text, nullable=False), + sa.Column("key_hash", sa.Text, nullable=False), + sa.Column("rate_limit_rps", sa.Integer, nullable=False, server_default=sa.text("3")), + sa.Column("monthly_quota", sa.Integer, nullable=True), + sa.Column("monthly_used", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column( + "resets_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("(now() + interval '1 month')"), + ), + sa.Column("revoked", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("last_used_at", sa.DateTime(timezone=True)), + sa.CheckConstraint("rate_limit_rps > 0", name="api_keys_rate_limit_positive"), + sa.CheckConstraint("monthly_used >= 0", name="api_keys_monthly_used_nonneg"), + sa.CheckConstraint( + "monthly_quota IS NULL OR monthly_quota >= 0", + name="api_keys_monthly_quota_nonneg", + ), + sa.UniqueConstraint("user_id", "name", name="api_keys_user_name_unique"), + ) + op.create_index("api_keys_user_idx", "api_keys", ["user_id"]) + op.create_index("api_keys_hash_idx", "api_keys", ["key_hash"]) + + # ── api_key_requests ───────────────────────────────────────────────────── + op.create_table( + "api_key_requests", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "api_key_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("api_keys.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "document_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("documents.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + op.create_index( + "api_key_requests_key_created_idx", + "api_key_requests", + ["api_key_id", sa.text("created_at DESC")], + ) + + +def downgrade() -> None: + op.drop_index("api_key_requests_key_created_idx", table_name="api_key_requests") + op.drop_table("api_key_requests") + op.drop_index("api_keys_hash_idx", table_name="api_keys") + op.drop_index("api_keys_user_idx", table_name="api_keys") + op.drop_table("api_keys") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e09559d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,152 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "contract-check" +version = "0.1.0" +description = "AI-скрининг рисков в договорах (PDF/DOCX) для СНГ — ГК РФ / ГК РБ. Event-driven production app." +readme = "README.md" +requires-python = ">=3.13" +license = { text = "Proprietary" } +authors = [{ name = "Контракт-чек" }] +keywords = ["legal", "contracts", "llm", "risk-screening", "fastapi", "rabbitmq"] + +# Core deps imported by EVERY service image (including the lean bot adapter). +# Keep this set minimal — service-specific libs go in dependency-groups below +# so each Docker image only installs what it needs. +dependencies = [ + "pydantic>=2.7", + "pydantic-settings>=2.3", + "structlog>=24.1", + "python-dotenv>=1.0", + "httpx[http2]>=0.27", +] + +[project.scripts] +# Stage-0 benchmark CLI (standalone, kept). Production services have their own +# entrypoints via `python -m contract_check.`. +contract-check = "contract_check.prototype:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/contract_check"] + +# ───────────────────────────────────────────────────────────────────────────── +# Dependency groups (PEP 735). Docker images: `uv sync --frozen --no-default-groups +# --group `. The project deps above (core) are always installed on top. +# +# api → core + db/mq/s3/obs + fastapi/uvicorn +# worker-extract → core + db/mq/s3/obs + pymupdf/tesseract +# worker-analyze → core + db/mq/s3/obs + otel-httpx +# bot → core + aiogram (leanest image: no db/mq/s3/llm/tesseract) +# prototype → core + pymupdf/python-docx (benchmark CLI) +# dev → everything needed to lint/typecheck/test locally +# ───────────────────────────────────────────────────────────────────────────── +[dependency-groups] +db = [ + "sqlalchemy>=2.0", + "asyncpg>=0.29", + "alembic>=1.13", +] +mq = [ + "aio-pika>=9.4", +] +s3 = [ + "minio>=7.2", +] +obs = [ + "prometheus-client>=0.20", + "sentry-sdk>=2", + "opentelemetry-sdk>=1.24", + "opentelemetry-exporter-otlp>=1.24", +] +api = [ + { include-group = "db" }, + { include-group = "mq" }, + { include-group = "s3" }, + { include-group = "obs" }, + "fastapi>=0.110", + "uvicorn[standard]>=0.29", + "python-multipart>=0.0.9", + "redis>=5.0", + "pyjwt[crypto]>=2.8", + "opentelemetry-instrumentation-fastapi>=0.45b0", + "opentelemetry-instrumentation-asgi>=0.45b0", + "opentelemetry-instrumentation-httpx>=0.45b0", +] +extract = [ + { include-group = "db" }, + { include-group = "mq" }, + { include-group = "s3" }, + { include-group = "obs" }, + "pymupdf>=1.24", + "pytesseract>=0.3.10", + "pillow>=10", +] +analyze = [ + { include-group = "db" }, + { include-group = "mq" }, + { include-group = "s3" }, + { include-group = "obs" }, + "opentelemetry-instrumentation-httpx>=0.45b0", +] +bot = [ + "aiogram>=3.4", +] +prototype = [ + "pymupdf>=1.24", + "python-docx>=1.1", +] +dev = [ + { include-group = "api" }, + { include-group = "extract" }, + { include-group = "analyze" }, + { include-group = "bot" }, + { include-group = "prototype" }, + "pytest>=8", + "pytest-asyncio>=0.23", + "respx>=0.21", + "ruff>=0.5", + "mypy>=1.10", + "anyio>=4", + "aiosqlite>=0.20", + "testcontainers[rabbitmq,postgres,minio]>=4", + "asgi-lifespan>=2.1.0", +] + +[tool.ruff] +line-length = 100 +target-version = "py313" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP", "B"] + +[tool.mypy] +python_version = "3.13" +strict = true +packages = ["contract_check"] +# Third-party libs without complete type stubs. Our own code stays fully typed. +disallow_untyped_calls = false + +[[tool.mypy.overrides]] +module = [ + "aio_pika.*", + "minio.*", + "prometheus_client.*", + "sentry_sdk.*", + "opentelemetry.*", + "pymupdf.*", + "docx.*", + "pytesseract.*", + "PIL.*", +] +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" +asyncio_mode = "auto" +markers = [ + "integration: slow tests needing real Postgres/RabbitMQ/MinIO (deselect with '-m \"not integration\"')", +] diff --git a/src/contract_check/__init__.py b/src/contract_check/__init__.py new file mode 100644 index 0000000..22d9bf7 --- /dev/null +++ b/src/contract_check/__init__.py @@ -0,0 +1,11 @@ +"""«Контракт-чек» — AI-скрининг рисков в договорах (СНГ / ГК РФ / ГК РБ). + +Production refactor (see docs/ARCHITECTURE.md): + - core/ shared domain (config, db, mq, s3, llm, analysis, credits, tokens) + - prototype/ stage-0 standalone benchmark CLI (preserved) + +Service packages (api/, worker_extract/, worker_analyze/, bot/) land in +Steps 2–5. Importable from each service's own Docker image. +""" + +__version__ = "0.1.0" diff --git a/src/contract_check/__main__.py b/src/contract_check/__main__.py new file mode 100644 index 0000000..33b0968 --- /dev/null +++ b/src/contract_check/__main__.py @@ -0,0 +1,6 @@ +"""Top-level entrypoint: `python -m contract_check ` → stage-0 prototype.""" + +from .prototype import main + +if __name__ == "__main__": + main() diff --git a/src/contract_check/api/__main__.py b/src/contract_check/api/__main__.py new file mode 100644 index 0000000..7f24926 --- /dev/null +++ b/src/contract_check/api/__main__.py @@ -0,0 +1,69 @@ +"""Entrypoint for the api image. + +Default: run uvicorn. Supports a seed-token command for operator bootstrapping: + + python -m contract_check.api + python -m contract_check.api seed-token bot-prod bot [optional-token] +""" + +from __future__ import annotations + +import asyncio +import sys + +import uvicorn + +from ..core.config import get_settings +from ..core.db.session import create_session_factory +from ..core.tokens import assert_adapter, generate_token, hash_token +from .app import create_app + + +async def _seed_token(name: str, adapter: str, token: str | None = None) -> None: + assert_adapter(adapter) + raw = token or generate_token() + factory = create_session_factory() + async with factory() as session: + from sqlalchemy import text + + await session.execute( + text( + "INSERT INTO service_tokens (name, token_hash, adapter) " + "VALUES (:name, :hash, :adapter) " + "ON CONFLICT (name) DO UPDATE SET " + " token_hash = EXCLUDED.token_hash, " + " revoked = FALSE, " + " adapter = EXCLUDED.adapter" + ), + {"name": name, "hash": hash_token(raw), "adapter": adapter}, + ) + await session.commit() + print(f"Token '{name}' ({adapter}) ready. Bearer:") + print(raw) + if token: + print("(provided by operator)") + else: + print("(generated — copy it now; only the hash is stored)") + + +def main() -> None: + args = sys.argv[1:] + if args and args[0] == "seed-token": + if len(args) not in (3, 4): + print("Usage: python -m contract_check.api seed-token NAME ADAPTER [TOKEN]") + sys.exit(1) + _, name, adapter, *provided = args + asyncio.run(_seed_token(name, adapter, provided[0] if provided else None)) + return + + settings = get_settings() + uvicorn.run( + create_app(), + host=settings.api_host, + port=settings.api_port, + log_config=None, # structlog handles logging + ) + + +if __name__ == "__main__": + main() diff --git a/src/contract_check/api/app.py b/src/contract_check/api/app.py new file mode 100644 index 0000000..48d1bba --- /dev/null +++ b/src/contract_check/api/app.py @@ -0,0 +1,96 @@ +"""FastAPI application factory for the «Контракт-чек» API (core service). + +The api is the only service that writes to Postgres/MinIO/RabbitMQ for user- +initiated document analysis. Adapters (bot, future web/cli) call it over HTTP. +See docs/ARCHITECTURE.md §15. +""" + +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI + +from ..core.config import get_settings +from ..core.llm import port as llm_port # noqa: F401 — package loaded +from ..core.logging import bind_context, configure_logging, get_logger +from ..core.metrics import redis_connected +from ..core.mq.publisher import Publisher +from ..core.rate_limit import MemoryRateLimiter, RateLimiter, RedisRateLimiter +from ..core.redis_client import get_redis_client +from ..core.s3.minio_storage import MinioStorage +from ..core.sentry import init_sentry +from ..core.telemetry import setup_telemetry, shutdown_telemetry +from .middleware import add_middleware +from .routes import auth, b2b, documents, health, me, metrics, reports + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> Any: + settings = get_settings() + configure_logging( + settings.log_level, + json_output=settings.json_logs, + service="api", + env=settings.env, + ) + bind_context(service="api", env=settings.env) + init_sentry("api") + setup_telemetry("api") + + storage = MinioStorage.from_endpoint_url( + endpoint_url=settings.s3_endpoint_url, + access_key=settings.s3_access_key, + secret_key=settings.s3_secret_key, + bucket=settings.s3_bucket, + region=settings.s3_region, + ) + await storage.ensure_bucket() + + publisher = Publisher(settings.rabbitmq_url, origin="api") + await publisher.connect() + + # Redis is used for rate-limiting/sessions. In dev/tests without Redis we + # transparently fall back to an in-memory bucket so unit tests stay + # dependency-free. + redis_client = get_redis_client(settings.redis_url) + rate_limiter: RateLimiter + try: + await redis_client.ping() + rate_limiter = RedisRateLimiter(redis_client) + redis_connected.set(1) + except Exception as exc: + log = get_logger(__name__) + log.warning("redis_unavailable", redis_url=settings.redis_url, error=str(exc)) + rate_limiter = MemoryRateLimiter() + redis_connected.set(0) + + app.state.storage = storage + app.state.publisher = publisher + app.state.rate_limiter = rate_limiter + + yield + + await publisher.close() + try: + await redis_client.aclose() + except Exception: + pass + shutdown_telemetry() + + +def create_app() -> FastAPI: + app = FastAPI( + title="Контракт-чек", + version="0.1.0", + description="AI-скрининг рисков в договорах.", + lifespan=lifespan, + ) + add_middleware(app) + app.include_router(health.router) + app.include_router(metrics.router) + app.include_router(auth.router) + app.include_router(documents.router) + app.include_router(reports.router) + app.include_router(me.router) + app.include_router(b2b.router) + return app diff --git a/src/contract_check/api/deps.py b/src/contract_check/api/deps.py new file mode 100644 index 0000000..a48efcb --- /dev/null +++ b/src/contract_check/api/deps.py @@ -0,0 +1,324 @@ +"""FastAPI dependencies (db session, storage, publisher, service-token auth).""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Annotated +from uuid import UUID + +from fastapi import Depends, Header, HTTPException, Request +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.api_keys import hash_api_key +from ..core.auth import AuthError, TokenExpiredError, TokenInvalidError, verify_access_token +from ..core.config import get_settings +from ..core.db.models import User +from ..core.db.session import create_session_factory +from ..core.logging import get_logger +from ..core.mq.publisher import Publisher +from ..core.rate_limit import RateLimiter +from ..core.s3.port import Storage +from ..core.tokens import hash_token + +log = get_logger(__name__) + + +async def get_db_session() -> AsyncIterator[AsyncSession]: + factory = create_session_factory() + async with factory() as session: + yield session + + +AsyncSessionDep = Annotated[AsyncSession, Depends(get_db_session)] + + +def get_storage(request: Request) -> Storage: + storage: Storage = request.app.state.storage + return storage + + +StorageDep = Annotated[Storage, Depends(get_storage)] + + +def get_publisher(request: Request) -> Publisher: + publisher: Publisher = request.app.state.publisher + return publisher + + +PublisherDep = Annotated[Publisher, Depends(get_publisher)] + + +async def require_service_token( + session: AsyncSessionDep, + authorization: Annotated[str | None, Header()] = None, +) -> None: + """Validate `Authorization: Bearer ` against service_tokens table. + + Raises 401 on missing/invalid/revoked token. Logs last_used_at on success. + """ + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") + + raw = authorization[7:].strip() + token_hash = hash_token(raw) + result = await session.execute( + text("SELECT id FROM service_tokens WHERE token_hash = :h AND revoked = FALSE"), + {"h": token_hash}, + ) + row = result.first() + if row is None: + raise HTTPException(status_code=401, detail="Invalid or revoked token") + + await session.execute( + text("UPDATE service_tokens SET last_used_at = now() WHERE id = :id"), + {"id": row[0]}, + ) + await session.commit() + + +AuthDep = Annotated[None, Depends(require_service_token)] + + +async def get_or_create_user_for_telegram(session: AsyncSession, telegram_id: int) -> User: + """Fetch or create a user identified by telegram_id.""" + result = await session.execute( + text("SELECT id, telegram_id, created_at, credits_left FROM users WHERE telegram_id = :t"), + {"t": telegram_id}, + ) + row = result.first() + if row: + return User(id=row[0], telegram_id=row[1], created_at=row[2], credits_left=row[3]) + + insert = await session.execute( + text( + "INSERT INTO users (telegram_id, credits_left) VALUES (:t, 0) " + "RETURNING id, telegram_id, created_at, credits_left" + ), + {"t": telegram_id}, + ) + new = insert.first() + assert new is not None + await session.commit() + return User(id=new[0], telegram_id=new[1], created_at=new[2], credits_left=new[3]) + + +async def get_or_create_user_by_id(session: AsyncSession, user_id: UUID) -> User | None: + """Fetch an existing user by UUID. Returns None if not found.""" + result = await session.execute( + text("SELECT id, telegram_id, created_at, credits_left FROM users WHERE id = :u"), + {"u": user_id}, + ) + row = result.first() + if row is None: + return None + return User(id=row[0], telegram_id=row[1], created_at=row[2], credits_left=row[3]) + + +def get_rate_limiter(request: Request) -> RateLimiter: + """Return the app-state rate limiter (Redis in prod, Memory in tests).""" + limiter: RateLimiter = request.app.state.rate_limiter + return limiter + + +RateLimiterDep = Annotated[RateLimiter, Depends(get_rate_limiter)] + + +class ApiKeyAuth: + """Validated B2B API key + its owning user id.""" + + def __init__(self, api_key_id: UUID, user_id: UUID, rate_limit_rps: int) -> None: + self.api_key_id = api_key_id + self.user_id = user_id + self.rate_limit_rps = rate_limit_rps + + +async def _maybe_reset_monthly_quota(session: AsyncSession, api_key_id: UUID) -> None: + """Reset monthly_used/resets_at if the quota window has expired.""" + await session.execute( + text( + "UPDATE api_keys " + "SET monthly_used = 0, resets_at = now() + interval '1 month' " + "WHERE id = :k AND resets_at < now()" + ), + {"k": api_key_id}, + ) + + +async def require_api_key( + session: AsyncSessionDep, + rate_limiter: RateLimiterDep, + x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, +) -> ApiKeyAuth: + """Validate `X-API-Key` header, rate-limit, and return key metadata. + + Raises 401 on missing/invalid/revoked key. Raises 429 when rate-limited + or monthly quota exhausted. + """ + if not x_api_key: + raise HTTPException(status_code=401, detail="Missing X-API-Key header") + + key_hash = hash_api_key(x_api_key) + result = await session.execute( + text( + "SELECT id, user_id, rate_limit_rps, monthly_quota, monthly_used, revoked " + "FROM api_keys " + "WHERE key_hash = :h" + ), + {"h": key_hash}, + ) + row = result.first() + if row is None: + raise HTTPException(status_code=401, detail="Invalid API key") + + api_key_id, user_id, rate_limit_rps, monthly_quota, monthly_used, revoked = row + if revoked: + raise HTTPException(status_code=401, detail="Revoked API key") + + await _maybe_reset_monthly_quota(session, api_key_id) + + # Check monthly quota (if configured) after potential reset. + if monthly_quota is not None and int(monthly_used) >= int(monthly_quota): + raise HTTPException(status_code=429, detail="Monthly quota exceeded") + + # Apply token-bucket rate limit per key id. + limit = int(rate_limit_rps or get_settings().b2b_default_rate_limit_rps) + rl_result = await rate_limiter.allow(f"rate_limit:{api_key_id}", limit) + if not rl_result.allowed: + retry_after = max(1, int(rl_result.retry_after_sec or 1)) + raise HTTPException( + status_code=429, + detail="Rate limit exceeded", + headers={"Retry-After": str(retry_after)}, + ) + + await session.execute( + text("UPDATE api_keys SET last_used_at = now() WHERE id = :id"), + {"id": api_key_id}, + ) + await session.commit() + + return ApiKeyAuth(api_key_id=api_key_id, user_id=user_id, rate_limit_rps=limit) + + +ApiKeyAuthDep = Annotated[ApiKeyAuth, Depends(require_api_key)] + + +async def fetch_document_status_for_user( + session: AsyncSession, document_id: UUID, user_id: UUID +) -> dict[str, object] | None: + """Fetch document + report scoped to a specific user (B2B API).""" + result = await session.execute( + text( + "SELECT d.id, d.status, d.stage, d.filename, d.created_at, " + " r.markdown, r.content_json, r.model_used, " + " r.prompt_tokens, r.eval_tokens, r.latency_ms " + "FROM documents d " + "LEFT JOIN reports r ON r.document_id = d.id " + "WHERE d.id = :d AND d.user_id = :u" + ), + {"d": document_id, "u": user_id}, + ) + row = result.first() + if row is None: + return None + return { + "id": row[0], + "status": row[1], + "stage": row[2], + "filename": row[3], + "created_at": row[4], + "markdown": row[5], + "content_json": row[6], + "model_used": row[7], + "prompt_tokens": row[8], + "eval_tokens": row[9], + "latency_ms": row[10], + } + + +class CurrentUser: + """Authenticated user extracted from a Bearer JWT.""" + + def __init__(self, user_id: UUID, telegram_id: int) -> None: + self.user_id = user_id + self.telegram_id = telegram_id + + +async def require_current_user( + session: AsyncSessionDep, + authorization: Annotated[str | None, Header()] = None, +) -> CurrentUser: + """Validate `Authorization: Bearer ` and return the user. + + This is the common auth gate for bot, web, and Mini App users. + """ + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") + + token = authorization[7:].strip() + try: + claims = verify_access_token(token) + except TokenExpiredError as exc: + raise HTTPException(status_code=401, detail="Token expired") from exc + except TokenInvalidError as exc: + raise HTTPException(status_code=401, detail="Invalid token") from exc + except AuthError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + + # Ensure the user still exists (defense in depth: tokens are stateless, + # but a deleted user should not be able to use them). + result = await session.execute( + text("SELECT id FROM users WHERE id = :u"), + {"u": claims.sub}, + ) + if result.first() is None: + raise HTTPException(status_code=401, detail="User not found") + + return CurrentUser(user_id=claims.sub, telegram_id=claims.telegram_id) + + +CurrentUserDep = Annotated[CurrentUser, Depends(require_current_user)] + + +async def fetch_document_status( + session: AsyncSession, document_id: UUID, user_id: UUID +) -> dict[str, object] | None: + result = await session.execute( + text( + "SELECT d.id, d.status, d.stage, d.filename, d.created_at, " + " r.markdown, r.content_json, r.model_used, " + " r.prompt_tokens, r.eval_tokens, r.latency_ms " + "FROM documents d " + "LEFT JOIN reports r ON r.document_id = d.id " + "WHERE d.id = :d AND d.user_id = :u" + ), + {"d": document_id, "u": user_id}, + ) + row = result.first() + if row is None: + return None + return { + "id": row[0], + "status": row[1], + "stage": row[2], + "filename": row[3], + "created_at": row[4], + "markdown": row[5], + "content_json": row[6], + "model_used": row[7], + "prompt_tokens": row[8], + "eval_tokens": row[9], + "latency_ms": row[10], + } + + +async def get_credits(session: AsyncSession, user_id: UUID) -> int: + result = await session.execute( + text("SELECT credits_left FROM users WHERE id = :u"), + {"u": user_id}, + ) + row = result.first() + if row is None: + return 0 + return int(row[0]) diff --git a/src/contract_check/api/middleware.py b/src/contract_check/api/middleware.py new file mode 100644 index 0000000..f91c4d3 --- /dev/null +++ b/src/contract_check/api/middleware.py @@ -0,0 +1,47 @@ +"""FastAPI middleware — correlation_id, request timing/metrics, Sentry errors.""" + +from __future__ import annotations + +import time +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from ..core.logging import get_logger, new_correlation_id, set_correlation_id +from ..core.metrics import http_request_duration +from ..core.sentry import init_sentry + +log = get_logger(__name__) + + +def add_middleware(app: FastAPI) -> None: + @app.middleware("http") + async def _middleware(request: Request, call_next: Any) -> Any: + cid = request.headers.get("x-correlation-id") or new_correlation_id() + set_correlation_id(cid) + start = time.perf_counter() + status = 200 + try: + response = await call_next(request) + status = response.status_code + response.headers["x-correlation-id"] = cid + return response + except Exception as exc: + status = 500 + log.error("unhandled_exception", path=request.url.path, error=str(exc)) + raise + finally: + duration = time.perf_counter() - start + http_request_duration.labels( + method=request.method, + path=request.url.path, + status=str(status), + ).observe(duration) + + @app.exception_handler(Exception) + async def _exception_handler(_request: Request, exc: Exception) -> JSONResponse: + log.error("exception_handler", error=str(exc)) + return JSONResponse(status_code=500, content={"detail": "internal error"}) + + init_sentry("api") diff --git a/src/contract_check/api/routes/README.md b/src/contract_check/api/routes/README.md new file mode 100644 index 0000000..fc1e46f --- /dev/null +++ b/src/contract_check/api/routes/README.md @@ -0,0 +1,397 @@ +# API Routers + +FastAPI-роутеры сервиса `contract_check.api`. Все пути — префикс `/api/v1` +(кроме инфра-эндпоинтов `/healthz`, `/readyz`, `/metrics`). + +Роутеры регистрируются в приложении через `include_router`; теги соответствуют +атрибуту `tags=` каждого `APIRouter`. + +## Содержание + +- [Аутентификация](#аутентификация) +- [health](#health) +- [metrics](#metrics) +- [auth](#auth) +- [me](#me) +- [documents](#documents) +- [reports](#reports) +- [b2b](#b2b) +- [Коды ошибок](#коды-ошибок) + +--- + +## Аутентификация + +Три независимые схемы, реализованные в [`api/deps.py`](../deps.py): + +| Зависимость | Заголовок | Кому выдаётся | Где используется | +| ------------------- | ---------------------------------- | -------------------------------------- | ---------------------------------------- | +| `AuthDep` | `Authorization: Bearer `| Сервисный токен (адаптеры bot/web/cli) | `/auth/telegram/*` — вход от имени бота | +| `CurrentUserDep` | `Authorization: Bearer ` | JWT пользователя (Telegram-логин) | `/me`, `/documents`, `/reports`, `/b2b/keys*` | +| `ApiKeyAuthDep` | `X-API-Key: ` | B2B-ключ внешнего клиента | `/analyze`, `/b2b/reports/*`, `/b2b/usage` | + +- Сервисный токен и B2B-ключ хешируются (`hash_token`, `hash_api_key`), + в БД хранится только хеш; raw-значение возвращается один раз при создании. +- `ApiKeyAuthDep` дополнительно применяет token-bucket rate limit и + месячный квоты (`429` при превышении). +- `CurrentUserDep` проверяет, что пользователь ещё есть в таблице `users` + (stateless JWT + defence-in-depth). + +--- + +## health + +Файл: [`health.py`](health.py). Тег: `health`. Без аутентификации. + +### `GET /healthz` + +Liveness-проба. Возвращает `200` всегда. + +```json +{ "status": "ok" } +``` + +### `GET /readyz` + +Readiness-проба. Выполняет `SELECT 1` в БД. + +```json +{ "status": "ok" } +``` + +Если БД недоступна: + +```json +{ "status": "not_ready", "reason": "db: " } +``` + +--- + +## metrics + +Файл: [`metrics.py`](metrics.py). Тег: `metrics`. Без аутентификации. + +### `GET /metrics` + +Exposition-эндпоинт Prometheus. Тело — текст в формате +`text/plain; version=0.0.4` (`prometheus_client.generate_latest`). + +--- + +## auth + +Файл: [`auth.py`](auth.py). Тег: `auth`. + +Три источника идентичности сходятся к одному JWT: + +### `POST /api/v1/auth/telegram/bot` + +Бот (aiogram) обменивает проверенный `telegram_id` на JWT пользователя. +**Auth:** `AuthDep` (сервисный токен бота). + +Тело — `TelegramBotAuthRequest`: + +| Поле | Тип | Условие | Описание | +| ------------ | ---- | ------------ | ------------------------------------- | +| `telegram_id`| int | `> 0` | Verified Telegram user id из aiogram | + +Ответ `200` — `AuthResponse` (см. ниже). + +### `POST /api/v1/auth/telegram/web` + +Callback Telegram Login Widget. **Auth:** нет (проверяется подпись Telegram). + +Тело — `TelegramWebAuthRequest`: + +| Поле | Тип | Условие | +| ----------- | --------- | ------- | +| `id` | int | `> 0` | +| `first_name`| str? | — | +| `last_name` | str? | — | +| `username` | str? | — | +| `photo_url` | str? | — | +| `auth_date` | int | — | +| `hash` | str | — | + +`401` при ошибке проверки подписи. Ответ `200` — `AuthResponse`. + +### `POST /api/v1/auth/telegram/miniapp` + +Mini App initData. **Auth:** нет (проверяется подпись ` initData`). + +Тело — `TelegramMiniAppAuthRequest`: + +| Поле | Тип | Описание | +| ---------- | --- | ------------------------------------------ | +| `init_data`| str | Raw initData query string из `Telegram.WebApp` | + +`401` при ошибке. Ответ `200` — `AuthResponse`. + +#### `AuthResponse` + +```json +{ + "access_token": "", + "token_type": "bearer", + "expires_in": 3600, + "user_id": "uuid", + "telegram_id": 123456789 +} +``` + +`expires_in` = `jwt_access_ttl_minutes * 60` из настроек. + +### `GET /api/v1/auth/me` + +Интроспекция Bearer-JWT. **Auth:** `Authorization: Bearer ` в заголовке. + +Ответ `200` — `TokenIntrospectResponse`: + +| Поле | Тип | +| ------------ | --- | +| `sub` | str (UUID) | +| `telegram_id`| int | +| `type` | str | +| `exp` | int (unix) | + +`401` — отсутствует/невалиден. + +### `GET /api/v1/auth/me/permissions` + +Заглушка под будущее RBAC. + +```json +{ "permissions": ["upload", "read_reports", "read_me"] } +``` + +--- + +## me + +Файл: [`me.py`](me.py). Тег: `me`. **Auth:** `CurrentUserDep` (user JWT). + +### `GET /api/v1/me` + +Профиль пользователя и баланс кредитов. + +```json +{ "telegram_id": 123456789, "credits_left": 7 } +``` + +--- + +## documents + +Файл: [`documents.py`](documents.py). Тег: `documents`. +**Auth:** `CurrentUserDep`. + +### `POST /api/v1/documents` + +Загрузить документ на анализ. Резервирует кредит, кладёт файл в MinIO, +создаёт строки `documents` + `jobs`, публикует `DocumentUploaded` в RabbitMQ. + +Запрос — `multipart/form-data`: + +| Поле | Тип | Описание | +| ----- | ----------- | ------------------------------------- | +| `file`| UploadFile | Расширения: `.pdf`, `.docx` (см. `SUPPORTED_SUFFIXES`) | + +Ответ `202` (результат [`upload_and_enqueue`](../services.py)): + +```json +{ + "document_id": "uuid", + "correlation_id": "uuid", + "credits_left": 6 +} +``` + +Ошибки: +- `400` — нет имени / неподдерживаемый формат / пустой файл; +- `402` — нет кредитов (`no credits available`); +- `500` — сбой MinIO/БД/RabbitMQ (кредит возвращается). + +--- + +## reports + +Файл: [`reports.py`](reports.py). Тег: `reports`. **Auth:** `CurrentUserDep`. + +### `GET /api/v1/reports/{document_id}` + +Опрос статуса/результата анализа. Документ обязан принадлежать +аутентифицированному пользователю. + +Параметр пути: `document_id` — UUID. + +Пока анализ не готов (`status != "done"`): + +```json +{ + "document_id": "uuid", + "status": "queued | extracting | analyzing | failed", + "stage": "" +} +``` + +Готовый отчёт (`status == "done"`): + +```json +{ + "document_id": "uuid", + "status": "done", + "filename": "contract.pdf", + "markdown": "", + "findings": { ... }, + "model_used": "", + "prompt_tokens": 1234, + "eval_tokens": 5678, + "latency_ms": 91011 +} +``` + +`404` — отчёт не найден (или чужой). + +--- + +## b2b + +Файл: [`b2b.py`](b2b.py). Тег: `b2b`. + +Анализ-эндпоинты используют **`X-API-Key`** (`ApiKeyAuthDep`). +Эндпоинты управления ключами используют **user JWT** (`CurrentUserDep`), +т.к. вызываются внутренними адаптерами от имени пользователя (`telegram_id`). + +### `POST /api/v1/analyze` + +Загрузить документ под B2B-ключом. **Auth:** `X-API-Key`. + +Запрос — `multipart/form-data` (`file`), как в `POST /api/v1/documents`. + +Резервирует кредит у владельца ключа, пишет `api_key_requests`, +инкрементит `api_keys.monthly_used`. Логирование usage — best-effort +(не валит загрузку при сбое записи). + +Ответ `202` — как у `POST /api/v1/documents`. + +### `GET /api/v1/b2b/reports/{document_id}` + +Опрос отчёта, scoped к владельцу ключа. **Auth:** `X-API-Key`. + +Параметр пути: `document_id` — UUID. + +Формат ответа — идентичен `GET /api/v1/reports/{document_id}`. +`404` — чужой/несуществующий. + +### `GET /api/v1/b2b/usage` + +Текущее использование ключа. **Auth:** `X-API-Key`. + +```json +{ + "api_key_id": "uuid", + "rate_limit_rps": 3, + "monthly_quota": 1000, + "monthly_used": 17, + "requests_this_month": 17, + "resets_at": "2026-09-01T00:00:00+00:00" +} +``` + +### `POST /api/v1/b2b/keys` + +Создать новый B2B-ключ. **Auth:** `CurrentUserDep`. + +Тело — `CreateApiKeyRequest`: + +| Поле | Тип | Default | Описание | +| ---------------- | ----- | ------- | ------------------------------ | +| `name` | str | — | Метка ключа | +| `rate_limit_rps` | int? | `3` | Должно быть `> 0` | +| `monthly_quota` | int? | `null` | Без лимита, если не задан | + +Ответ `201` — **raw-ключ возвращается только один раз**: + +```json +{ + "api_key": "sk_b2b_...", + "id": "uuid", + "name": "prod-webhooks", + "rate_limit_rps": 3, + "monthly_quota": null, + "monthly_used": 0, + "revoked": false, + "created_at": "2026-08-12T..." +} +``` + +`400` — `rate_limit_rps` не положителен; `500` — сбой БД. + +### `GET /api/v1/b2b/keys` + +Список ключей пользователя. **Auth:** `CurrentUserDep`. + +Ответ — массив объектов формы `ApiKeyResponse`: + +| Поле | Тип | +| ---------------- | -------- | +| `id` | UUID | +| `name` | str | +| `rate_limit_rps` | int | +| `monthly_quota` | int? | +| `monthly_used` | int | +| `revoked` | bool | +| `created_at` | ISO-str | + +Сортировка — `created_at DESC`. Raw-ключи **не** возвращаются. + +### `POST /api/v1/b2b/keys/{key_id}/revoke` + +Отозвать ключ. **Auth:** `CurrentUserDep`. Параметр пути: `key_id` — UUID. + +```json +{ "id": "uuid", "revoked": true } +``` + +`404` — ключ не найден, чужой или уже отозван. + +### `GET /api/v1/b2b/keys/{key_id}/usage` + +Помесячное использование конкретного ключа. **Auth:** `CurrentUserDep`. + +```json +{ + "key_id": "uuid", + "name": "prod-webhooks", + "rate_limit_rps": 3, + "monthly_quota": 1000, + "monthly_used": 17, + "resets_at": "2026-09-01T...", + "monthly_requests": [ + { "month": "2026-08-01T...", "requests": 17 } + ] +} +``` + +`404` — ключ не найден или чужой. + +--- + +## Коды ошибок + +Общий формат ошибки FastAPI: + +```json +{ "detail": "<сообщение>" } +``` + +| Код | Когда | +| --- | ----------------------------------------------------- | +| 400 | Неверный запрос (bad format / пустой файл / bad arg) | +| 401 | Нет/невалиден токен или API-ключ | +| 402 | `no credits available` (при резерве кредита) | +| 404 | Ресурс не найден или чужой | +| 429 | Rate limit / месячный квот B2B-ключа исчерпан | +| 500 | Сбой инфраструктуры (MinIO/PG/RabbitMQ) | + +При `429` от rate limiter добавляется заголовок `Retry-After`. diff --git a/src/contract_check/api/routes/__init__.py b/src/contract_check/api/routes/__init__.py new file mode 100644 index 0000000..d1e594b --- /dev/null +++ b/src/contract_check/api/routes/__init__.py @@ -0,0 +1 @@ +"""API routes package.""" diff --git a/src/contract_check/api/routes/auth.py b/src/contract_check/api/routes/auth.py new file mode 100644 index 0000000..6b430cf --- /dev/null +++ b/src/contract_check/api/routes/auth.py @@ -0,0 +1,148 @@ +"""Telegram-based authentication endpoints. + +Three identity sources converge on the same JWT: + - /auth/telegram/bot — bot adapter exchanges a verified telegram_id for JWT + - /auth/telegram/web — Telegram Login Widget callback + - /auth/telegram/miniapp — Mini App initData + +Protected user endpoints receive the JWT via `Authorization: Bearer ` and use +`deps.CurrentUser`. +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Header, HTTPException, status +from pydantic import BaseModel, Field + +from ...core.auth import ( + AuthError, + create_access_token, + verify_access_token, + verify_bot_identity, + verify_telegram_miniapp_init_data, + verify_telegram_web_payload, +) +from ...core.config import get_settings +from ...core.db.models import User +from ..deps import AsyncSessionDep, AuthDep, get_or_create_user_for_telegram + +router = APIRouter(tags=["auth"]) + + +class TelegramBotAuthRequest(BaseModel): + telegram_id: int = Field(..., gt=0, description="Verified Telegram user id from aiogram") + + +class TelegramWebAuthRequest(BaseModel): + id: int = Field(..., gt=0) + first_name: str | None = None + last_name: str | None = None + username: str | None = None + photo_url: str | None = None + auth_date: int + hash: str + + +class TelegramMiniAppAuthRequest(BaseModel): + init_data: str = Field(..., description="Raw initData query string from Telegram.WebApp") + + +class AuthResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + user_id: str + telegram_id: int + + +class TokenIntrospectResponse(BaseModel): + sub: str + telegram_id: int + type: str + exp: int + + +def _issue_token(user: User) -> AuthResponse: + token = create_access_token(user.id, user.telegram_id or 0) + ttl = get_settings().jwt_access_ttl_minutes * 60 + return AuthResponse( + access_token=token, + token_type="bearer", + expires_in=ttl, + user_id=str(user.id), + telegram_id=user.telegram_id or 0, + ) + + +@router.post("/api/v1/auth/telegram/bot", status_code=status.HTTP_200_OK) +async def auth_telegram_bot( + auth: AuthDep, + session: AsyncSessionDep, + body: TelegramBotAuthRequest, +) -> AuthResponse: + """Exchange a verified telegram_id (from the bot) for a user JWT.""" + identity = verify_bot_identity(body.telegram_id) + user = await get_or_create_user_for_telegram(session, identity.telegram_id) + return _issue_token(user) + + +@router.post("/api/v1/auth/telegram/web", status_code=status.HTTP_200_OK) +async def auth_telegram_web( + session: AsyncSessionDep, + body: TelegramWebAuthRequest, +) -> AuthResponse: + """Verify Telegram Login Widget payload and issue a user JWT.""" + bot_token = get_settings().telegram_bot_token + try: + identity = verify_telegram_web_payload(body.model_dump(), bot_token) + except AuthError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc + + user = await get_or_create_user_for_telegram(session, identity.telegram_id) + return _issue_token(user) + + +@router.post("/api/v1/auth/telegram/miniapp", status_code=status.HTTP_200_OK) +async def auth_telegram_miniapp( + session: AsyncSessionDep, + body: TelegramMiniAppAuthRequest, +) -> AuthResponse: + """Verify Telegram Mini App initData and issue a user JWT.""" + bot_token = get_settings().telegram_bot_token + try: + identity = verify_telegram_miniapp_init_data(body.init_data, bot_token) + except AuthError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc + + user = await get_or_create_user_for_telegram(session, identity.telegram_id) + return _issue_token(user) + + +@router.get("/api/v1/auth/me", status_code=status.HTTP_200_OK) +async def introspect_token( + authorization: Annotated[str | None, Header()] = None, +) -> TokenIntrospectResponse: + """Debug/introspection endpoint: return claims for a Bearer JWT.""" + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token") + + token = authorization[7:].strip() + try: + claims = verify_access_token(token) + except AuthError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc + + return TokenIntrospectResponse( + sub=str(claims.sub), + telegram_id=claims.telegram_id, + type=claims.type, + exp=claims.exp or 0, + ) + + +@router.get("/api/v1/auth/me/permissions") +async def token_permissions_dummy() -> dict[str, Any]: + """Placeholder for future RBAC expansion.""" + return {"permissions": ["upload", "read_reports", "read_me"]} diff --git a/src/contract_check/api/routes/b2b.py b/src/contract_check/api/routes/b2b.py new file mode 100644 index 0000000..53eb682 --- /dev/null +++ b/src/contract_check/api/routes/b2b.py @@ -0,0 +1,294 @@ +"""B2B API routes: external clients analyze contracts via `X-API-Key`. + +Management endpoints (create/revoke/list keys) use service-token auth because they +are called by internal adapters (web/cli) on behalf of a user identified by +`telegram_id`. Analysis endpoints use `X-API-Key` auth only. +""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, File, HTTPException, UploadFile, status +from pydantic import BaseModel +from sqlalchemy import text + +from ...core.api_keys import generate_api_key, hash_api_key +from ...core.logging import get_logger +from ..deps import ( + ApiKeyAuthDep, + AsyncSessionDep, + CurrentUserDep, + PublisherDep, + StorageDep, + fetch_document_status_for_user, +) +from ..services import upload_and_enqueue + +log = get_logger(__name__) + +router = APIRouter(tags=["b2b"]) + + +@router.post("/api/v1/analyze", status_code=status.HTTP_202_ACCEPTED) +async def analyze_document( + auth: ApiKeyAuthDep, + session: AsyncSessionDep, + storage: StorageDep, + publisher: PublisherDep, + file: Annotated[UploadFile, File()] = ..., # type: ignore[assignment] +) -> dict[str, object]: + """Upload a document for analysis using a B2B API key. + + Reserves one credit from the key owner's account, stores the file, enqueues + the extraction job, and returns 202 + document_id/correlation_id for polling. + """ + result = await upload_and_enqueue(session, storage, publisher, auth.user_id, file) + + # Record usage for this key. + try: + await session.execute( + text("INSERT INTO api_key_requests (api_key_id, document_id) VALUES (:k, :d)"), + {"k": auth.api_key_id, "d": uuid.UUID(str(result["document_id"]))}, + ) + await session.execute( + text("UPDATE api_keys SET monthly_used = monthly_used + 1 WHERE id = :k"), + {"k": auth.api_key_id}, + ) + await session.commit() + except Exception as exc: + log.error( + "api_key_request_log_failed", + api_key_id=str(auth.api_key_id), + document_id=result["document_id"], + error=str(exc), + ) + # Usage logging is best-effort; do not fail the upload. + + return result + + +@router.get("/api/v1/b2b/reports/{document_id}") +async def get_b2b_report( + auth: ApiKeyAuthDep, + session: AsyncSessionDep, + document_id: uuid.UUID, +) -> dict[str, object]: + """Poll for an analysis report scoped to the API key owner.""" + row = await fetch_document_status_for_user(session, document_id, auth.user_id) + if row is None: + raise HTTPException(status_code=404, detail="report not found") + + doc_status = row["status"] + if doc_status != "done": + return { + "document_id": str(document_id), + "status": doc_status, + "stage": row["stage"], + } + + return { + "document_id": str(document_id), + "status": doc_status, + "filename": row["filename"], + "markdown": row["markdown"], + "findings": row["content_json"], + "model_used": row["model_used"], + "prompt_tokens": row["prompt_tokens"], + "eval_tokens": row["eval_tokens"], + "latency_ms": row["latency_ms"], + } + + +@router.get("/api/v1/b2b/usage") +async def get_usage( + auth: ApiKeyAuthDep, + session: AsyncSessionDep, +) -> dict[str, object]: + """Return current-month usage for the authenticating API key.""" + result = await session.execute( + text("SELECT monthly_quota, monthly_used, resets_at FROM api_keys WHERE id = :k"), + {"k": auth.api_key_id}, + ) + row = result.first() + assert row is not None + + quota, used, resets_at = row + requests_result = await session.execute( + text( + "SELECT COUNT(*) FROM api_key_requests " + "WHERE api_key_id = :k AND created_at >= DATE_TRUNC('month', now())" + ), + {"k": auth.api_key_id}, + ) + requests_this_month = int(requests_result.scalar_one()) + + return { + "api_key_id": str(auth.api_key_id), + "rate_limit_rps": auth.rate_limit_rps, + "monthly_quota": quota, + "monthly_used": used, + "requests_this_month": requests_this_month, + "resets_at": resets_at.isoformat() if resets_at else None, + } + + +# ── Key management (service-token auth + telegram_id) ──────────────────────── + + +class CreateApiKeyRequest(BaseModel): + name: str + rate_limit_rps: int | None = None + monthly_quota: int | None = None + + +class ApiKeyResponse(BaseModel): + id: uuid.UUID + name: str + rate_limit_rps: int + monthly_quota: int | None + monthly_used: int + revoked: bool + created_at: str + + +@router.post("/api/v1/b2b/keys", status_code=status.HTTP_201_CREATED) +async def create_api_key( + session: AsyncSessionDep, + user: CurrentUserDep, + body: CreateApiKeyRequest, +) -> dict[str, object]: + """Create a new B2B API key for the authenticated user. + + The raw key is returned **only once**; afterwards only its hash is stored. + """ + rate_limit = body.rate_limit_rps or 3 + if rate_limit <= 0: + raise HTTPException(status_code=400, detail="rate_limit_rps must be positive") + + raw_key = generate_api_key() + key_hash = hash_api_key(raw_key) + + try: + result = await session.execute( + text( + "INSERT INTO api_keys (user_id, name, key_hash, rate_limit_rps, monthly_quota) " + "VALUES (:u, :n, :h, :r, :q) " + "RETURNING id, name, rate_limit_rps, monthly_quota, monthly_used, revoked, created_at" + ), + { + "u": user.user_id, + "n": body.name, + "h": key_hash, + "r": rate_limit, + "q": body.monthly_quota, + }, + ) + await session.commit() + except Exception as exc: + log.error("api_key_create_failed", user_id=str(user.user_id), error=str(exc)) + raise HTTPException(status_code=500, detail="failed to create API key") from exc + + row = result.first() + assert row is not None + return { + "api_key": raw_key, + "id": str(row[0]), + "name": row[1], + "rate_limit_rps": row[2], + "monthly_quota": row[3], + "monthly_used": row[4], + "revoked": row[5], + "created_at": row[6].isoformat() if row[6] else None, + } + + +@router.get("/api/v1/b2b/keys") +async def list_api_keys( + session: AsyncSessionDep, + user: CurrentUserDep, +) -> list[dict[str, object]]: + """List B2B API keys for the authenticated user.""" + result = await session.execute( + text( + "SELECT id, name, rate_limit_rps, monthly_quota, monthly_used, revoked, created_at " + "FROM api_keys WHERE user_id = :u ORDER BY created_at DESC" + ), + {"u": user.user_id}, + ) + return [ + { + "id": str(row[0]), + "name": row[1], + "rate_limit_rps": row[2], + "monthly_quota": row[3], + "monthly_used": row[4], + "revoked": row[5], + "created_at": row[6].isoformat() if row[6] else None, + } + for row in result.all() + ] + + +@router.post("/api/v1/b2b/keys/{key_id}/revoke") +async def revoke_api_key( + session: AsyncSessionDep, + user: CurrentUserDep, + key_id: uuid.UUID, +) -> dict[str, object]: + """Revoke a B2B API key. Only keys owned by the user may be revoked.""" + result = await session.execute( + text( + "UPDATE api_keys SET revoked = TRUE " + "WHERE id = :k AND user_id = :u AND revoked = FALSE " + "RETURNING id" + ), + {"k": key_id, "u": user.user_id}, + ) + await session.commit() + if result.first() is None: + raise HTTPException(status_code=404, detail="key not found or already revoked") + return {"id": str(key_id), "revoked": True} + + +@router.get("/api/v1/b2b/keys/{key_id}/usage") +async def get_key_usage( + session: AsyncSessionDep, + user: CurrentUserDep, + key_id: uuid.UUID, +) -> dict[str, object]: + """Return per-month usage for a specific API key owned by the user.""" + key_result = await session.execute( + text( + "SELECT name, rate_limit_rps, monthly_quota, monthly_used, resets_at " + "FROM api_keys WHERE id = :k AND user_id = :u" + ), + {"k": key_id, "u": user.user_id}, + ) + key_row = key_result.first() + if key_row is None: + raise HTTPException(status_code=404, detail="key not found") + + monthly_result = await session.execute( + text( + "SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) " + "FROM api_key_requests " + "WHERE api_key_id = :k " + "GROUP BY month ORDER BY month DESC" + ), + {"k": key_id}, + ) + + return { + "key_id": str(key_id), + "name": key_row[0], + "rate_limit_rps": key_row[1], + "monthly_quota": key_row[2], + "monthly_used": key_row[3], + "resets_at": key_row[4].isoformat() if key_row[4] else None, + "monthly_requests": [ + {"month": row[0].isoformat() if row[0] else None, "requests": int(row[1])} + for row in monthly_result.all() + ], + } diff --git a/src/contract_check/api/routes/documents.py b/src/contract_check/api/routes/documents.py new file mode 100644 index 0000000..f95395b --- /dev/null +++ b/src/contract_check/api/routes/documents.py @@ -0,0 +1,26 @@ +"""Document upload endpoint: reserve credit, store in MinIO, enqueue to RabbitMQ.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, File, UploadFile, status + +from ...core.logging import get_logger +from ..deps import AsyncSessionDep, CurrentUserDep, PublisherDep, StorageDep +from ..services import upload_and_enqueue + +log = get_logger(__name__) + +router = APIRouter(tags=["documents"]) + + +@router.post("/api/v1/documents", status_code=status.HTTP_202_ACCEPTED) +async def upload_document( + session: AsyncSessionDep, + storage: StorageDep, + publisher: PublisherDep, + user: CurrentUserDep, + file: Annotated[UploadFile, File()] = ..., # type: ignore[assignment] +) -> dict[str, object]: + return await upload_and_enqueue(session, storage, publisher, user.user_id, file) diff --git a/src/contract_check/api/routes/health.py b/src/contract_check/api/routes/health.py new file mode 100644 index 0000000..0b57e28 --- /dev/null +++ b/src/contract_check/api/routes/health.py @@ -0,0 +1,24 @@ +"""Health endpoints: /healthz (liveness) and /readyz (readiness).""" + +from __future__ import annotations + +from fastapi import APIRouter +from sqlalchemy import text + +from ..deps import AsyncSessionDep + +router = APIRouter(tags=["health"]) + + +@router.get("/healthz") +async def healthz() -> dict[str, str]: + return {"status": "ok"} + + +@router.get("/readyz") +async def readyz(session: AsyncSessionDep) -> dict[str, str]: + try: + await session.execute(text("SELECT 1")) + except Exception as exc: + return {"status": "not_ready", "reason": f"db: {exc}"} + return {"status": "ok"} diff --git a/src/contract_check/api/routes/me.py b/src/contract_check/api/routes/me.py new file mode 100644 index 0000000..210075c --- /dev/null +++ b/src/contract_check/api/routes/me.py @@ -0,0 +1,18 @@ +"""User profile endpoint (credits balance).""" + +from __future__ import annotations + +from fastapi import APIRouter + +from ..deps import AsyncSessionDep, CurrentUserDep, get_credits + +router = APIRouter(tags=["me"]) + + +@router.get("/api/v1/me") +async def me( + session: AsyncSessionDep, + user: CurrentUserDep, +) -> dict[str, object]: + credits = await get_credits(session, user.user_id) + return {"telegram_id": user.telegram_id, "credits_left": credits} diff --git a/src/contract_check/api/routes/metrics.py b/src/contract_check/api/routes/metrics.py new file mode 100644 index 0000000..f1a33eb --- /dev/null +++ b/src/contract_check/api/routes/metrics.py @@ -0,0 +1,13 @@ +"""Prometheus metrics endpoint.""" + +from __future__ import annotations + +from fastapi import APIRouter, Response +from prometheus_client import CONTENT_TYPE_LATEST, generate_latest + +router = APIRouter(tags=["metrics"]) + + +@router.get("/metrics") +async def metrics() -> Response: + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/src/contract_check/api/routes/reports.py b/src/contract_check/api/routes/reports.py new file mode 100644 index 0000000..6d67db1 --- /dev/null +++ b/src/contract_check/api/routes/reports.py @@ -0,0 +1,42 @@ +"""Report polling endpoint.""" + +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, HTTPException + +from ..deps import AsyncSessionDep, CurrentUserDep, fetch_document_status + +router = APIRouter(tags=["reports"]) + + +@router.get("/api/v1/reports/{document_id}") +async def get_report( + session: AsyncSessionDep, + document_id: UUID, + user: CurrentUserDep, +) -> dict[str, object]: + row = await fetch_document_status(session, document_id, user.user_id) + if row is None: + raise HTTPException(status_code=404, detail="report not found") + + status = row["status"] + if status != "done": + return { + "document_id": str(document_id), + "status": status, + "stage": row["stage"], + } + + return { + "document_id": str(document_id), + "status": status, + "filename": row["filename"], + "markdown": row["markdown"], + "findings": row["content_json"], + "model_used": row["model_used"], + "prompt_tokens": row["prompt_tokens"], + "eval_tokens": row["eval_tokens"], + "latency_ms": row["latency_ms"], + } diff --git a/src/contract_check/api/services.py b/src/contract_check/api/services.py new file mode 100644 index 0000000..5e58931 --- /dev/null +++ b/src/contract_check/api/services.py @@ -0,0 +1,149 @@ +"""Shared api upload/enqueue service (used by Telegram and B2B adapters).""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from fastapi import HTTPException +from sqlalchemy import text + +from ..core.analysis.extractor import SUPPORTED_SUFFIXES +from ..core.credits import reserve_credit +from ..core.logging import get_logger, new_correlation_id +from ..core.metrics import credits_reserved, documents_uploaded +from ..core.mq.messages import DocumentUploaded +from ..core.mq.topology import RK_EXTRACT +from ..core.s3 import original_key + +if TYPE_CHECKING: + from fastapi import UploadFile + from sqlalchemy.ext.asyncio import AsyncSession + + from ..core.mq.publisher import Publisher + from ..core.s3.port import Storage + +log = get_logger(__name__) + + +def _content_type_from_suffix(suffix: str) -> str: + return { + ".pdf": "application/pdf", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }.get(suffix, "application/octet-stream") + + +async def upload_and_enqueue( + session: AsyncSession, + storage: Storage, + publisher: Publisher, + user_id: uuid.UUID, + file: UploadFile, +) -> dict[str, object]: + """Reserve credit, store file in MinIO, create document/job rows, publish to queue. + + Returns 202 payload: {document_id, correlation_id, credits_left}. + Raises HTTPException on validation, credit, storage, DB, or MQ failure. + """ + if file.filename is None: + raise HTTPException(status_code=400, detail="filename is required") + + suffix = file.filename.lower().split(".")[-1] if "." in file.filename else "" + suffix = f".{suffix}" if suffix else "" + if suffix not in SUPPORTED_SUFFIXES: + raise HTTPException( + status_code=400, + detail=f"unsupported format {suffix!r}; supported: {sorted(SUPPORTED_SUFFIXES)}", + ) + + if not await reserve_credit(session, user_id): + raise HTTPException(status_code=402, detail="no credits available") + await session.commit() + credits_reserved.inc() + + document_id = uuid.uuid4() + correlation_id = new_correlation_id() + s3_key = original_key(str(user_id), str(document_id), suffix) + content_type = file.content_type or _content_type_from_suffix(suffix) + + try: + data = await file.read() + if len(data) == 0: + raise HTTPException(status_code=400, detail="empty file") + await storage.put(s3_key, data, content_type=content_type) + except HTTPException: + raise + except Exception as exc: + log.error("s3_upload_failed", document_id=str(document_id), error=str(exc)) + await session.execute( + text("UPDATE users SET credits_left = credits_left + 1 WHERE id = :u"), + {"u": user_id}, + ) + await session.commit() + raise HTTPException(status_code=500, detail="failed to store document") from exc + + try: + await session.execute( + text( + "INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) " + "VALUES (:id, :uid, :s3, :fn, :mime, :bytes, 'queued')" + ), + { + "id": document_id, + "uid": user_id, + "s3": s3_key, + "fn": file.filename, + "mime": content_type, + "bytes": len(data), + }, + ) + await session.execute( + text( + "INSERT INTO jobs (document_id, correlation_id, queue, status) " + "VALUES (:did, :cid, 'extract', 'pending')" + ), + {"did": document_id, "cid": correlation_id}, + ) + await session.commit() + except Exception as exc: + log.error("db_enqueue_failed", document_id=str(document_id), error=str(exc)) + await session.execute( + text("UPDATE users SET credits_left = credits_left + 1 WHERE id = :u"), + {"u": user_id}, + ) + await session.commit() + raise HTTPException(status_code=500, detail="failed to enqueue document") from exc + + try: + msg = DocumentUploaded( + correlation_id=uuid.UUID(correlation_id), + document_id=document_id, + user_id=user_id, + s3_key=s3_key, + filename=file.filename, + mime=content_type, + ) + await publisher.publish(msg, routing_key=RK_EXTRACT) + documents_uploaded.inc() + except Exception as exc: + log.error("mq_publish_failed", document_id=str(document_id), error=str(exc)) + await session.execute( + text("UPDATE users SET credits_left = credits_left + 1 WHERE id = :u"), + {"u": user_id}, + ) + await session.execute( + text("UPDATE documents SET status = 'failed', stage = 'publish_failed' WHERE id = :id"), + {"id": document_id}, + ) + await session.commit() + raise HTTPException(status_code=500, detail="failed to publish job") from exc + + credits = await session.execute( + text("SELECT credits_left FROM users WHERE id = :u"), + {"u": user_id}, + ) + return { + "document_id": str(document_id), + "correlation_id": str(correlation_id), + "credits_left": int(credits.scalar_one()), + } diff --git a/src/contract_check/bot/__init__.py b/src/contract_check/bot/__init__.py new file mode 100644 index 0000000..cdf7bfb --- /dev/null +++ b/src/contract_check/bot/__init__.py @@ -0,0 +1,6 @@ +"""Telegram-bot adapter (aiogram 3, HTTP-only). + +The leanest service image: speaks to the api exclusively over HTTP and touches +no infrastructure directly (no DB/S3/MQ/LLM). The hexagonal boundary is enforced +by `tests/unit/test_bot_boundary.py`. See docs/ARCHITECTURE.md §4, §17. +""" diff --git a/src/contract_check/bot/__main__.py b/src/contract_check/bot/__main__.py new file mode 100644 index 0000000..ce9d06f --- /dev/null +++ b/src/contract_check/bot/__main__.py @@ -0,0 +1,63 @@ +"""Bot entrypoint: configure logging, wire the api client, start aiogram polling.""" + +from __future__ import annotations + +import asyncio +import signal + +from aiogram import Bot, Dispatcher + +from ..core.logging import bind_context, configure_logging, get_logger +from .client import ApiClient +from .config import get_bot_settings +from .handlers import router as bot_router + +log = get_logger(__name__) + + +async def main() -> None: + settings = get_bot_settings() + configure_logging( + settings.log_level, + json_output=settings.json_logs, + service="bot", + env=settings.env, + ) + bind_context(service="bot", env=settings.env) + + api = ApiClient(settings) + await api.start() + + bot = Bot(token=settings.bot_token) + dp = Dispatcher(api=api, settings=settings) + dp.include_router(bot_router) + + me = await bot.get_me() + log.info("bot_started", username=me.username, api_url=settings.api_url) + + loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop_event.set) + except NotImplementedError: + pass + + polling = asyncio.create_task(dp.start_polling(bot, handle_signals=False, polling_timeout=30)) + stopper = asyncio.create_task(stop_event.wait()) + + try: + await asyncio.wait({polling, stopper}, return_when=asyncio.FIRST_COMPLETED) + finally: + if not polling.done(): + polling.cancel() + if not stopper.done(): + stopper.cancel() + await dp.stop_polling() + await bot.session.close() + await api.aclose() + log.info("bot_stopped") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/contract_check/bot/client.py b/src/contract_check/bot/client.py new file mode 100644 index 0000000..9f57cc9 --- /dev/null +++ b/src/contract_check/bot/client.py @@ -0,0 +1,199 @@ +"""HTTP client to the contract-check api. + +The bot touches the api exclusively over HTTP (hexagonal boundary). Every +request carries the service-token bearer and a per-interaction `X-Correlation-ID` +so api/worker logs line up under one id across the whole pipeline +(docs/ARCHITECTURE.md §13). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +from ..core.logging import get_logger +from .config import BotSettings + +log = get_logger(__name__) + +DISCLAIMER_MARKER = "Дисклеймер" +FALLBACK_DISCLAIMER = ( + "\n\n> ⚠️ **Дисклеймер.** Это первичный скрининг, а не юридическая консультация. " + "Находки могут содержать ошибки. Перед подписанием договор проверяет юрист." +) + + +def ensure_disclaimer(markdown: str) -> str: + """Return markdown guaranteed to carry a disclaimer (append only if missing). + + The api's `render_markdown` already appends one; this is the defensive net + required by docs/ARCHITECTURE.md §17 ("bot appends if api omitted"). + """ + if DISCLAIMER_MARKER in markdown: + return markdown + return markdown.rstrip() + FALLBACK_DISCLAIMER + + +class ApiError(Exception): + """Unexpected api response.""" + + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(f"api error {status_code}: {detail}") + self.status_code = status_code + self.detail = detail + + +class NoCreditsError(ApiError): + """api returned 402 — the user has no credits left.""" + + +class UnsupportedFormatError(ApiError): + """api returned 400 — bad mime/size/extension.""" + + +@dataclass(slots=True) +class UploadResult: + document_id: str + correlation_id: str + credits_left: int + + +@dataclass(slots=True) +class ReportStatus: + document_id: str + status: str + stage: str | None + markdown: str | None = None + filename: str | None = None + + +def _extract_detail(response: httpx.Response) -> str: + try: + body = response.json() + except Exception: + return response.text + detail = body.get("detail") if isinstance(body, dict) else None + return detail if isinstance(detail, str) else str(detail) + + +class ApiClient: + """Thin httpx wrapper around the api endpoints the bot needs. + + The bot authenticates adapter-level calls (POST /auth/telegram/bot) with its + service token. All user-scoped calls carry a user JWT obtained from that + endpoint, not a raw telegram_id query parameter. + """ + + def __init__(self, settings: BotSettings) -> None: + self._settings = settings + self._client: httpx.AsyncClient | None = None + self._token_cache: dict[int, str] = {} + + async def start(self) -> None: + if self._client is None: + self._client = httpx.AsyncClient( + base_url=self._settings.api_url, + timeout=httpx.Timeout(30.0, connect=5.0), + ) + + async def aclose(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + @property + def client(self) -> httpx.AsyncClient: + if self._client is None: + raise RuntimeError("ApiClient not started; call start() first") + return self._client + + def _service_headers(self, correlation_id: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._settings.bot_service_token}", + "X-Correlation-ID": correlation_id, + } + + def _user_headers(self, telegram_id: int, correlation_id: str) -> dict[str, str]: + token = self._token_cache.get(telegram_id) + if not token: + raise RuntimeError("No JWT cached for telegram_id; call login() first") + return { + "Authorization": f"Bearer {token}", + "X-Correlation-ID": correlation_id, + } + + async def login(self, telegram_id: int, correlation_id: str) -> None: + """Exchange a verified telegram_id for a user JWT and cache it.""" + r = await self.client.post( + "/api/v1/auth/telegram/bot", + json={"telegram_id": telegram_id}, + headers=self._service_headers(correlation_id), + ) + if r.status_code != 200: + raise ApiError(r.status_code, _extract_detail(r)) + body = r.json() + token = body.get("access_token") + if not isinstance(token, str): + raise ApiError(500, "auth response missing access_token") + self._token_cache[telegram_id] = token + + def forget(self, telegram_id: int) -> None: + self._token_cache.pop(telegram_id, None) + + async def get_credits(self, telegram_id: int, correlation_id: str) -> int: + r = await self.client.get( + "/api/v1/me", + headers=self._user_headers(telegram_id, correlation_id), + ) + if r.status_code != 200: + raise ApiError(r.status_code, _extract_detail(r)) + return int(r.json().get("credits_left", 0)) + + async def upload_document( + self, + telegram_id: int, + correlation_id: str, + filename: str, + data: bytes, + content_type: str, + ) -> UploadResult: + files = {"file": (filename, data, content_type)} + r = await self.client.post( + "/api/v1/documents", + files=files, + headers=self._user_headers(telegram_id, correlation_id), + ) + if r.status_code == 202: + body = r.json() + return UploadResult( + document_id=body["document_id"], + correlation_id=body["correlation_id"], + credits_left=int(body.get("credits_left", 0)), + ) + detail = _extract_detail(r) + if r.status_code == 402: + raise NoCreditsError(r.status_code, detail) + if r.status_code == 400: + raise UnsupportedFormatError(r.status_code, detail) + raise ApiError(r.status_code, detail) + + async def get_report( + self, telegram_id: int, correlation_id: str, document_id: str + ) -> ReportStatus: + r = await self.client.get( + f"/api/v1/reports/{document_id}", + headers=self._user_headers(telegram_id, correlation_id), + ) + if r.status_code == 404: + raise ApiError(404, "report not found") + if r.status_code != 200: + raise ApiError(r.status_code, _extract_detail(r)) + body = r.json() + return ReportStatus( + document_id=body["document_id"], + status=body["status"], + stage=body.get("stage"), + markdown=body.get("markdown"), + filename=body.get("filename"), + ) diff --git a/src/contract_check/bot/config.py b/src/contract_check/bot/config.py new file mode 100644 index 0000000..0f51b55 --- /dev/null +++ b/src/contract_check/bot/config.py @@ -0,0 +1,53 @@ +"""Bot-only configuration (pydantic-settings). + +Deliberately independent of `core.config.Settings`, which carries the DB/MQ/S3 +secrets the bot must never need (docs/ARCHITECTURE.md §11). The bot image installs +no DB/MQ/S3 driver, so requiring those env vars would be wrong; the only secrets +the bot holds are its Telegram token and the service-token bearer used to call +the api. +""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class BotSettings(BaseSettings): + """Telegram-bot adapter settings. See docs/ARCHITECTURE.md §11 (Bot).""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + env: str = "dev" + log_level: str = "INFO" + + bot_token: str = Field(..., description="Telegram bot token from @BotFather") + api_url: str = Field( + default="http://api:8000", + description="Base URL of the api service (HTTP-only target).", + ) + bot_service_token: str = Field( + ..., + description="Bearer token authenticating the bot against the api (service_tokens).", + ) + + poll_interval_initial: float = 1.0 + poll_interval_max: float = 5.0 + poll_timeout: float = 300.0 + long_message_threshold: int = 4096 + + @property + def json_logs(self) -> bool: + return self.env != "dev" + + +@lru_cache +def get_bot_settings() -> BotSettings: + return BotSettings() # type: ignore[call-arg] diff --git a/src/contract_check/bot/handlers.py b/src/contract_check/bot/handlers.py new file mode 100644 index 0000000..479b110 --- /dev/null +++ b/src/contract_check/bot/handlers.py @@ -0,0 +1,262 @@ +"""aiogram 3 handlers: /start, document upload → poll → deliver report. + +HTTP-only (docs/ARCHITECTURE.md §17). No DB/S3/MQ/LLM imports — the boundary is +enforced statically by `tests/unit/test_bot_boundary.py`. + +Flow: + /start → GET /api/v1/me → greeting + credit balance. + PDF/DOCX → forward multipart to POST /api/v1/documents (Bearer) → + react to 402 (no credit) / 400 (bad format) / 202 (accepted) → + poll GET /api/v1/reports/{id} with backoff, surfacing `stage` → + on done send the report (attachment if >4096 chars, disclaimer + guaranteed); on failed notify the user. +""" + +from __future__ import annotations + +import asyncio +import io + +from aiogram import Bot, F, Router +from aiogram.filters import Command +from aiogram.types import BufferedInputFile, Document, Message + +from ..core.logging import get_logger, new_correlation_id +from .client import ( + ApiClient, + ApiError, + NoCreditsError, + ReportStatus, + UnsupportedFormatError, + ensure_disclaimer, +) +from .config import BotSettings + +log = get_logger(__name__) + +router = Router(name="contract-check-bot") + +_SUPPORTED_SUFFIXES = (".pdf", ".docx") +_CONTENT_TYPES = { + ".pdf": "application/pdf", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +} +_STAGE_LABELS: dict[str, str] = { + "queued": "Документ принят. В очереди на обработку…", + "extracting": "Извлекаю текст из документа…", + "ocr": "Документ похож на скан — распознаю страницы (OCR)…", + "analyzing": "Анализирую риски по чек-листу…", +} + + +def _suffix(name: str) -> str: + dot = name.rfind(".") + return name[dot:].lower() if dot >= 0 else "" + + +def _user_id(message: Message) -> int: + return message.from_user.id if message.from_user else 0 + + +@router.message(Command("start", "help")) +async def cmd_start(message: Message, api: ApiClient) -> None: + cid = new_correlation_id() + tg = _user_id(message) + try: + await api.login(tg, cid) + credits = await api.get_credits(tg, cid) + except ApiError as exc: + log.error("me_failed", correlation_id=cid, error=str(exc)) + await message.answer( + "Привет! Я «Контракт-чек» — скрининг рисков в договорах.\n" + "Не удалось связаться с сервисом, попробуйте позже." + ) + return + await message.answer( + "Привет! Я «Контракт-чек» — первичный скрининг рисков в договорах " + "по ГК РФ / ГК РБ.\n\n" + "Пришлите PDF или DOCX договор — я проверю его по чек-листу и пришлю " + "отчёт с рисками и рекомендациями.\n\n" + f"Осталось проверок: {credits}." + ) + + +@router.message(F.document) +async def handle_document(message: Message, api: ApiClient, settings: BotSettings) -> None: + cid = new_correlation_id() + tg = _user_id(message) + document: Document | None = message.document + if document is None or document.file_name is None: + await message.answer("Пришлите файл договора (PDF или DOCX).") + return + + suffix = _suffix(document.file_name) + if suffix not in _SUPPORTED_SUFFIXES: + await message.answer( + f"Поддерживаются только форматы PDF и DOCX. Получил: {suffix or 'без расширения'}." + ) + return + + status_msg = await message.answer("Скачиваю файл…") + bot = message.bot + if bot is None: + await _edit(status_msg, "Внутренняя ошибка: бот недоступен.") + return + + data = await _download(bot, document.file_id, cid) + if data is None: + await _edit(status_msg, "Не удалось скачать файл. Попробуйте ещё раз.") + return + + content_type = document.mime_type or _CONTENT_TYPES[suffix] + try: + await api.login(tg, cid) + upload = await api.upload_document(tg, cid, document.file_name, data, content_type) + except NoCreditsError: + await _edit(status_msg, "У вас закончились проверки. Пополните баланс, чтобы продолжить.") + return + except UnsupportedFormatError: + await _edit(status_msg, "Сервис не принял формат файла. Пришлите корректный PDF или DOCX.") + return + except ApiError as exc: + log.error( + "upload_failed", + correlation_id=cid, + status=exc.status_code, + detail=exc.detail, + ) + await _edit(status_msg, "Не удалось отправить документ на анализ. Попробуйте позже.") + return + + log.info( + "document_uploaded", + correlation_id=cid, + document_id=upload.document_id, + credits_left=upload.credits_left, + ) + await _edit(status_msg, _STAGE_LABELS["queued"]) + + await _poll_and_deliver( + api=api, + settings=settings, + bot=bot, + chat_id=message.chat.id, + status_msg=status_msg, + correlation_id=cid, + telegram_id=tg, + document_id=upload.document_id, + filename=document.file_name, + ) + + +async def _download(bot: Bot, file_id: str, correlation_id: str) -> bytes | None: + try: + buf = io.BytesIO() + await bot.download(file_id, destination=buf) + except Exception as exc: + log.error("tg_download_failed", correlation_id=correlation_id, error=str(exc)) + return None + return buf.getvalue() + + +async def _poll_and_deliver( + *, + api: ApiClient, + settings: BotSettings, + bot: Bot, + chat_id: int, + status_msg: Message, + correlation_id: str, + telegram_id: int, + document_id: str, + filename: str, +) -> None: + interval = settings.poll_interval_initial + elapsed = 0.0 + last_label = _STAGE_LABELS["queued"] + + while elapsed < settings.poll_timeout: + try: + report = await api.get_report(telegram_id, correlation_id, document_id) + except ApiError as exc: + log.warning( + "report_poll_failed", + correlation_id=correlation_id, + status=exc.status_code, + detail=exc.detail, + ) + await _sleep(elapsed, settings.poll_timeout, interval) + elapsed += interval + interval = _next_interval(interval, settings.poll_interval_max) + continue + + if report.status == "done" and report.markdown is not None: + await _deliver_report(bot, chat_id, report, filename, settings.long_message_threshold) + return + if report.status == "failed": + await _edit( + status_msg, + "Не удалось обработать документ. Проверка списана не будет — " + "попробуйте другой файл.", + ) + return + + label = _stage_label(report) + if label != last_label: + await _edit(status_msg, label) + last_label = label + + if not await _sleep(elapsed, settings.poll_timeout, interval): + break + elapsed += interval + interval = _next_interval(interval, settings.poll_interval_max) + + await _edit( + status_msg, + "Анализ занимает больше обычного. Попробуйте прислать документ ещё раз через минуту.", + ) + + +def _next_interval(current: float, cap: float) -> float: + return min(current * 1.5, cap) + + +async def _sleep(elapsed: float, timeout: float, interval: float) -> bool: + """Sleep `interval`, but never past the overall `timeout`. False if at budget end.""" + remaining = timeout - elapsed + if remaining <= 0: + return False + await asyncio.sleep(min(interval, remaining)) + return True + + +def _stage_label(report: ReportStatus) -> str: + stage = report.stage or report.status + return _STAGE_LABELS.get(stage, _STAGE_LABELS.get(report.status, "Обрабатываю…")) + + +async def _deliver_report( + bot: Bot, + chat_id: int, + report: ReportStatus, + filename: str, + threshold: int, +) -> None: + markdown = ensure_disclaimer(report.markdown or "") + if len(markdown) <= threshold: + await bot.send_message(chat_id, markdown) + return + safe_name = filename.rsplit(".", 1)[0] if "." in filename else filename + attachment = BufferedInputFile(markdown.encode("utf-8"), filename=f"{safe_name}-report.md") + await bot.send_document( + chat_id, + attachment, + caption="Отчёт получился объёмным — отправляю как файл.", + ) + + +async def _edit(message: Message, text: str) -> None: + try: + await message.edit_text(text) + except Exception as exc: + log.debug("edit_skipped", error=str(exc)) diff --git a/src/contract_check/core/__init__.py b/src/contract_check/core/__init__.py new file mode 100644 index 0000000..41b85dd --- /dev/null +++ b/src/contract_check/core/__init__.py @@ -0,0 +1,20 @@ +"""«Контракт-чек» core — shared domain package. + +Imported by every service image (api, worker-extract, worker-analyze) and the +prototype benchmark. Adapters (bot) deliberately do NOT import this package +beyond config/logging (hexagonal boundary — see docs/ARCHITECTURE.md §4). + +Subpackages: + config — typed env config (pydantic-settings) + logging — structlog JSON + correlation_id propagation + telemetry — OpenTelemetry init + sentry — Sentry init + metrics — Prometheus counters/histograms + metrics HTTP server + db — SQLAlchemy 2 models (6 tables), async session, enums + mq — RabbitMQ topology, publisher (confirms), consumer (retry/DLQ) + s3 — Storage port + MinIO adapter + llm — LLM provider port + Ollama Cloud adapter + factory + analysis — extractor, chunker, checklist, report_schema, analyzer, ocr + credits — reserve/refund billing invariants + tokens — service-token hashing/verification (no FastAPI here) +""" diff --git a/src/contract_check/core/analysis/__init__.py b/src/contract_check/core/analysis/__init__.py new file mode 100644 index 0000000..0be1d85 --- /dev/null +++ b/src/contract_check/core/analysis/__init__.py @@ -0,0 +1,4 @@ +"""Analysis domain — document text extraction, chunking, checklist, report +schema, OCR, and the analyzer (prompt building, finding merge/dedupe/sort, +markdown rendering). Imported by the LLM adapter and the prototype benchmark. +""" diff --git a/src/contract_check/core/analysis/analyzer.py b/src/contract_check/core/analysis/analyzer.py new file mode 100644 index 0000000..31332f3 --- /dev/null +++ b/src/contract_check/core/analysis/analyzer.py @@ -0,0 +1,129 @@ +"""Analyzer — prompt building, finding merge/dedupe/sort, markdown rendering. + +Pure logic (no LLM, no DB, no S3). Shared by the Ollama Cloud adapter +(chunk fan-out → merge), the worker-analyze handler (render final report), and +the stage-0 prototype benchmark. Kept dependency-free so it cannot form an +import cycle with `core.llm`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from ..logging import is_debug_enabled +from .checklist import CHECKLIST +from .report_schema import Finding + +DISCLAIMER = ( + "> ⚠️ **Дисклеймер.** Это первичный скрининг на основе открытой языковой " + "модели, а не юридическая консультация. Находки могут содержать ошибки и " + "не учитывают полную картину отношений сторон. Перед подписанием договор " + "обязательно проверяет квалифицированный юрист. Текст договора может " + "обрабатываться вне РБ/РФ (cloud-инференс)." +) + + +@dataclass(slots=True) +class RenderMetrics: + """Aggregate run metrics, attached to every rendered report.""" + + chars: int = 0 + chunks: int = 0 + findings: int = 0 + prompt_tokens: int = 0 + eval_tokens: int = 0 + latency_sec: float = 0.0 + models_used: set[str] = field(default_factory=set) + fell_back: bool = False + repaired: bool = False + broken_json: int = 0 + + @property + def total_tokens(self) -> int: + return self.prompt_tokens + self.eval_tokens + + +def build_user_prompt(chunk: str) -> str: + return ( + "Проанализируй договор по чек-листу и верни находки в JSON по схеме.\n\n" + "=== ТЕКСТ ДОГОВОРА (фрагмент) ===\n" + f"{chunk}\n" + "=== КОНЕЦ ТЕКСТА ===\n\n" + 'Верни JSON: {"findings": [ ... ]}. Только реальные риски, с цитатами ' + "и ссылкой на пункт оригинала." + ) + + +def dedupe_findings(findings: list[Finding]) -> list[Finding]: + seen: set[tuple[str, str]] = set() + out: list[Finding] = [] + for f in findings: + key = (f.checklist_id, f.quote.strip().lower()[:200]) + if key in seen: + continue + seen.add(key) + out.append(f) + return out + + +def sort_findings(findings: list[Finding]) -> list[Finding]: + order = {item.id: i for i, item in enumerate(CHECKLIST)} + sev_rank = {"high": 0, "medium": 1, "low": 2} + return sorted( + findings, + key=lambda f: (order.get(f.checklist_id, 999), sev_rank.get(f.severity, 9)), + ) + + +def checklist_title(checklist_id: str) -> str: + for item in CHECKLIST: + if item.id == checklist_id: + return item.title + return checklist_id + + +def render_markdown(findings: list[Finding], source: str, metrics: RenderMetrics) -> str: + lines: list[str] = [] + lines.append(f"# Отчёт по договору: `{Path(source).name}`\n") + lines.append(f"**Найдено рисков:** {len(findings)}\n") + + if not findings: + lines.append( + "_Явных рисков по чек-листу не найдено. Это не юридическое заключение " + "об отсутствии рисков — отдельные формулировки стоит проверить юристу._\n" + ) + else: + sev_emoji = {"high": "🔴 Высокий", "medium": "🟡 Средний", "low": "🟢 Низкий"} + for i, f in enumerate(findings, start=1): + title = checklist_title(f.checklist_id) + lines.append(f"## {i}. {title} — {sev_emoji.get(f.severity, f.severity)}\n") + lines.append(f"**Пункт договора:** {f.section_ref}\n") + lines.append("**Цитата из оригинала:**") + lines.append(f"> {f.quote}\n") + lines.append(f"**Риск:** {f.risk or checklist_title(f.checklist_id)}\n") + if f.recommendation: + lines.append(f"**Рекомендация:** {f.recommendation}\n") + + lines.append("---\n") + lines.append(DISCLAIMER + "\n") + + if is_debug_enabled(): + lines.append("\n
Метрики прогона\n") + lines.append("```") + lines.append(f"chars: {metrics.chars}") + lines.append(f"chunks: {metrics.chunks}") + lines.append(f"findings: {metrics.findings}") + lines.append( + f"tokens total: {metrics.total_tokens} " + f"(prompt {metrics.prompt_tokens} + eval {metrics.eval_tokens})" + ) + lines.append(f"latency sec: {metrics.latency_sec:.1f}") + lines.append(f"models: {', '.join(sorted(metrics.models_used)) or '-'}") + lines.append(f"fell back: {metrics.fell_back}") + lines.append( + f"repaired: {metrics.repaired} (broken-json attempts: {metrics.broken_json})" + ) + lines.append("```") + lines.append("
\n") + return "\n".join(lines) diff --git a/src/contract_check/core/analysis/checklist.py b/src/contract_check/core/analysis/checklist.py new file mode 100644 index 0000000..96d6980 --- /dev/null +++ b/src/contract_check/core/analysis/checklist.py @@ -0,0 +1,108 @@ +"""Чек-лист анализа договора (v1). + +Один список, без БД. Источник: docs/IMPLEMENTATION_PLAN.md «Чек-лист пунктов анализа». +Каждый пункт — то, что модель ищет в тексте договора. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ChecklistItem: + id: str + title: str + description: str + + +CHECKLIST: list[ChecklistItem] = [ + ChecklistItem( + id="penalties", + title="Неустойки / штрафы", + description=( + "Размер неустойки или штрафа, её соразмерность, право стороны " + "взимать её в одностороннем порядке, двойные санкции за одно нарушение." + ), + ), + ChecklistItem( + id="jurisdiction", + title="Подсудность", + description=( + "Рассмотрение споров в «своём» регионе одной из сторон, обход " + "потребительского / претензионного порядка, суд чужого государства." + ), + ), + ChecklistItem( + id="payment_terms", + title="Сроки и условия оплаты", + description=( + "Размытые сроки, авансы без возврата, право сдвигать срок оплаты " + "или изменять цену, просрочка и её последствия." + ), + ), + ChecklistItem( + id="intellectual_property", + title="Интеллектуальные права (IP)", + description=( + "Кому отходят исключительные права на результаты, отчуждение " + "сверх необходимого, лицензия шире цели договора, служебные произведения." + ), + ), + ChecklistItem( + id="unilateral_changes", + title="Одностороннее изменение условий", + description=( + "Право одной стороны менять цену, тариф, объём, условия " + "оказания без согласия второй стороны." + ), + ), + ChecklistItem( + id="warranties", + title="Гарантии и их срок", + description=( + "Срок гарантии, порядок предъявления требований по гарантии, " + "исключения из гарантии, бремя доказывания недостатков." + ), + ), + ChecklistItem( + id="force_majeure", + title="Форс-мажор", + description=( + "Узкий перечень обстоятельств, исключение «экономических» форс-мажоров, " + "срок уведомления и бремя доказывания форс-мажора." + ), + ), + ChecklistItem( + id="vat", + title="НДС", + description=( + "Включён НДС в цену или начисляется сверх, кто несёт налоговую " + "нагрузку, право сторон менять режим налогообложения." + ), + ), + ChecklistItem( + id="liability", + title="Ответственность сторон", + description=( + "Кап (cap) ответственности, исключения косвенных/упущенных убытков, " + "асимметрия ответственности между сторонами." + ), + ), + ChecklistItem( + id="termination", + title="Расторжение", + description=( + "Условия и срок уведомления о расторжении, право одностороннего " + "отказа, штрафы за досрочное расторжение, возврат аванса." + ), + ), +] + + +def checklist_for_prompt() -> str: + """Render the checklist as a numbered list for the LLM prompt.""" + lines = [] + for i, item in enumerate(CHECKLIST, start=1): + lines.append(f"{i}. [{item.id}] {item.title} — {item.description}") + return "\n".join(lines) diff --git a/src/contract_check/core/analysis/chunker.py b/src/contract_check/core/analysis/chunker.py new file mode 100644 index 0000000..c34cfea --- /dev/null +++ b/src/contract_check/core/analysis/chunker.py @@ -0,0 +1,69 @@ +"""Разбиение длинного текста на чанки под контекст модели. + +Граница — по абзацам / предложениям, чтобы не рвать смысл. Оставляем чанки +одинакового размера по символам; перекрытий нет (риски локальны в пределах пункта). +""" + +from __future__ import annotations + +import re + +_SENTENCE_END = re.compile(r"(?<=[.!?…])\s+") + + +def chunk_text(text: str, max_chars: int = 10000) -> list[str]: + """Разрезать text на куски ≤ max_chars с сохранением границ предложений.""" + if max_chars <= 0: + raise ValueError("max_chars must be positive") + if len(text) <= max_chars: + return [text] + + units: list[str] = [] + for para in text.split("\n"): + para = para.strip() + if not para: + continue + if len(para) <= max_chars: + units.append(para) + else: + units.extend(_split_sentences(para, max_chars)) + + chunks: list[str] = [] + buf = "" + for unit in units: + candidate = f"{buf}\n{unit}" if buf else unit + if len(candidate) <= max_chars: + buf = candidate + else: + if buf: + chunks.append(buf) + if len(unit) > max_chars: + for i in range(0, len(unit), max_chars): + chunks.append(unit[i : i + max_chars]) + buf = "" + else: + buf = unit + if buf: + chunks.append(buf) + return chunks + + +def _split_sentences(para: str, max_chars: int) -> list[str]: + parts = _SENTENCE_END.split(para) + out: list[str] = [] + buf = "" + for s in parts: + candidate = f"{buf} {s}".strip() if buf else s + if len(candidate) <= max_chars: + buf = candidate + else: + if buf: + out.append(buf) + if len(s) <= max_chars: + buf = s + else: + out.extend(s[i : i + max_chars] for i in range(0, len(s), max_chars)) + buf = "" + if buf: + out.append(buf) + return out diff --git a/src/contract_check/core/analysis/extractor.py b/src/contract_check/core/analysis/extractor.py new file mode 100644 index 0000000..fe18b7b --- /dev/null +++ b/src/contract_check/core/analysis/extractor.py @@ -0,0 +1,80 @@ +"""Экстрактор текста: PDF/DOCX → str. + +PDF (с текстовым слоем) — pymupdf. DOCX — python-docx. +Сканы здесь не обрабатываются (это OCR, см. core.analysis.ocr); пустой/битый +файл → ExtractionError. Лibraries imported lazily so the analyzer package can +be imported without pymupdf/python-docx installed. +""" + +from __future__ import annotations + +from pathlib import Path + +from ..logging import get_logger + +log = get_logger(__name__) + +SUPPORTED_SUFFIXES = {".pdf", ".docx"} + + +class ExtractionError(Exception): + """Файл пуст, бит или формат не поддерживается.""" + + +def extract_text(path: str | Path) -> str: + """Достать текст из PDF/DOCX. Поднять ExtractionError, если пусто/бито.""" + p = Path(path) + if not p.exists(): + raise ExtractionError(f"Файл не найден: {p}") + if p.suffix.lower() == ".pdf": + text = _extract_pdf(p) + elif p.suffix.lower() == ".docx": + text = _extract_docx(p) + else: + raise ExtractionError( + f"Неподдерживаемый формат '{p.suffix}'. Поддерживаются: " + f"{', '.join(sorted(SUPPORTED_SUFFIXES))}." + ) + + stripped = text.strip() + if len(stripped) < 100: + raise ExtractionError( + f"Извлечено слишком мало текста ({len(stripped)} симв.). " + "Возможно, это скан — нужен OCR, либо файл повреждён." + ) + log.info("extracted", file=p.name, chars=len(stripped)) + return stripped + + +def _extract_pdf(p: Path) -> str: + import pymupdf + + try: + doc = pymupdf.open(p) + except Exception as exc: # pymupdf кидает разные типы + raise ExtractionError(f"Не удалось открыть PDF {p.name}: {exc}") from exc + parts: list[str] = [] + try: + for page_index in range(doc.page_count): + page_text = doc[page_index].get_text("text") + if page_text: + parts.append(page_text) + finally: + doc.close() + return "\n".join(parts).strip() + + +def _extract_docx(p: Path) -> str: + from docx import Document + + try: + doc = Document(str(p)) + except Exception as exc: + raise ExtractionError(f"Не удалось открыть DOCX {p.name}: {exc}") from exc + parts = [para.text for para in doc.paragraphs if para.text.strip()] + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells if c.text.strip()] + if cells: + parts.append(" | ".join(cells)) + return "\n".join(parts).strip() diff --git a/src/contract_check/core/analysis/ocr.py b/src/contract_check/core/analysis/ocr.py new file mode 100644 index 0000000..0a4919a --- /dev/null +++ b/src/contract_check/core/analysis/ocr.py @@ -0,0 +1,65 @@ +"""OCR fallback for scanned PDFs (Tesseract, rus+eng). + +Triggered by the extract worker only when `extract_text` returns < 100 chars +(a strong scan signal). Rasterizes each PDF page via pymupdf and runs +pytesseract. Both libs are imported lazily so this module imports cleanly even +in images without tesseract/pymupdf installed (the bot/analyze images). + +Yandex Vision is a future alternative behind the same `ocr_pdf(path)` entry. +""" + +from __future__ import annotations + +from pathlib import Path + +from ..logging import get_logger +from .extractor import ExtractionError + +log = get_logger(__name__) + + +class OCRError(Exception): + """OCR failed (Tesseract unavailable, rasterization failed, no text).""" + + +def ocr_pdf(path: str | Path, *, lang: str = "rus+eng") -> str: + """Rasterize and OCR a PDF → plaintext. Raises OCRError on failure.""" + p = Path(path) + try: + import pymupdf + import pytesseract + from PIL import Image + except ImportError as exc: + raise OCRError(f"OCR backend not installed: {exc.name}") from exc + + try: + doc = pymupdf.open(p) + except Exception as exc: # pymupdf кидает разные типы + raise OCRError(f"Не удалось открыть PDF для OCR {p.name}: {exc}") from exc + + parts: list[str] = [] + try: + for page_index in range(doc.page_count): + page = doc[page_index] + pix = page.get_pixmap(dpi=300) + img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) + text = pytesseract.image_to_string(img, lang=lang) + if text: + parts.append(text) + except pytesseract.pytesseract.TesseractError as exc: + # Tesseract itself failed (missing language data, etc.) — surface as + # an OCR infra failure so the retry/DLQ path can refund appropriately. + raise OCRError(f"Tesseract engine failed for {p.name}: {exc}") from exc + except Exception as exc: + raise OCRError(f"OCR failed for {p.name}: {exc}") from exc + finally: + doc.close() + + stripped = "\n".join(parts).strip() + if len(stripped) < 100: + raise ExtractionError( + f"OCR тоже дал мало текста ({len(stripped)} симв.). " + "Файл, видимо, не содержит распознаваемого текста." + ) + log.info("ocr_done", file=p.name, chars=len(stripped)) + return stripped diff --git a/src/contract_check/core/analysis/report_schema.py b/src/contract_check/core/analysis/report_schema.py new file mode 100644 index 0000000..26179f4 --- /dev/null +++ b/src/contract_check/core/analysis/report_schema.py @@ -0,0 +1,62 @@ +"""Pydantic schema of the analysis report. + +Used both for client-side validation (repair-loop in the LLM adapter) and for +generating the JSON-schema handed to Ollama via `format: `. + +Open models routinely ignore the requested field names and emit their own +(e.g. ``risk_type`` instead of ``checklist_id``, ``description`` instead of +``risk``, and they often omit ``recommendation`` entirely). To keep the +repair-loop quiet we accept the common model aliases on input while exposing +the canonical names to the rest of the codebase. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +Severity = Literal["high", "medium", "low"] + + +class Finding(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + checklist_id: str = Field( + ..., + validation_alias=AliasChoices("checklist_id", "risk_type", "type", "category"), + description="id пункта чек-листа, напр. 'penalties', 'jurisdiction'.", + ) + severity: Severity = Field( + ..., + description="high / medium / low — уровень риска для стороны клиента.", + ) + quote: str = Field( + ..., + validation_alias=AliasChoices("quote", "citation", "text"), + description="Дословная цитата из оригинала договора (без пересказа).", + ) + section_ref: str = Field( + ..., + validation_alias=AliasChoices("section_ref", "section", "clause", "point"), + description="Номер/название пункта договора, чтобы проверить одним кликом.", + ) + risk: str = Field( + default="", + validation_alias=AliasChoices("risk", "description", "risk_description", "risk_text"), + description="В чём конкретно риск для стороны клиента.", + ) + recommendation: str = Field( + default="", + validation_alias=AliasChoices("recommendation", "recommendation_text", "advice"), + description="Что предложить изменить / уточнить в договоре.", + ) + + +class ReportPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + findings: list[Finding] = Field( + default_factory=list, + description="Только реальные находки. Если по пункту риска нет — не включаем.", + ) diff --git a/src/contract_check/core/api_keys.py b/src/contract_check/core/api_keys.py new file mode 100644 index 0000000..080ae36 --- /dev/null +++ b/src/contract_check/core/api_keys.py @@ -0,0 +1,26 @@ +"""B2B API-key utilities: generation, hashing, verification. + +Just like service tokens, the raw key is shown only once; we store SHA-256. +""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets + + +def generate_api_key() -> str: + """Mint a new opaque B2B API key (~43 URL-safe chars).""" + return secrets.token_urlsafe(32) + + +def hash_api_key(key: str) -> str: + """SHA-256 hex digest of an API key (store this, never the raw key).""" + return hashlib.sha256(key.encode("utf-8")).hexdigest() + + +def verify_api_key(key: str, key_hash: str) -> bool: + """Constant-time check that `key` matches the stored `key_hash`.""" + digest = hash_api_key(key) + return hmac.compare_digest(digest, key_hash) diff --git a/src/contract_check/core/auth.py b/src/contract_check/core/auth.py new file mode 100644 index 0000000..78ef311 --- /dev/null +++ b/src/contract_check/core/auth.py @@ -0,0 +1,267 @@ +"""User authentication helpers: JWT signing/verification and Telegram identity checks. + +This module is part of `core` and may be imported by the API. It does NOT depend +on DB/S3/MQ — only on pydantic-settings + python-jose-style JWT via PyJWT + +standard library hmac/hashlib. + +Supports three Telegram identity sources: + 1. Bot adapter: the bot already got a verified `telegram_id` from Telegram and + uses a service token to exchange it for a user JWT. + 2. Telegram Login Widget: web callback payload signed by Telegram. + 3. Telegram Mini App: `initData` signed by Telegram. + +All sources issue the same JWT containing `sub` (user UUID) and `telegram_id`. +""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import hmac +import secrets +import uuid +from dataclasses import dataclass +from typing import Any +from urllib.parse import parse_qsl + +import jwt + +from .config import get_settings +from .logging import get_logger + +log = get_logger(__name__) + +JWT_TYPE_ACCESS = "access" + + +class AuthError(Exception): + """Raised when an identity proof cannot be verified or a token is invalid.""" + + +class TokenExpiredError(AuthError): + """JWT has expired.""" + + +class TokenInvalidError(AuthError): + """JWT is malformed or signature is bad.""" + + +@dataclass(slots=True) +class UserIdentity: + """Verified user identity returned by Telegram identity checks.""" + + telegram_id: int + + +@dataclass(slots=True) +class AccessTokenClaims: + """Payload we put into (and expect from) an access JWT.""" + + sub: uuid.UUID # user_id + telegram_id: int + type: str + exp: int | None = None # unix seconds + + def to_dict(self) -> dict[str, Any]: + return { + "sub": str(self.sub), + "telegram_id": self.telegram_id, + "type": self.type, + "exp": self.exp, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> AccessTokenClaims: + return cls( + sub=uuid.UUID(str(payload["sub"])), + telegram_id=int(payload["telegram_id"]), + type=str(payload.get("type", JWT_TYPE_ACCESS)), + exp=payload.get("exp"), + ) + + +def _jwt_secret() -> str: + return get_settings().jwt_secret + + +def _jwt_algorithm() -> str: + return get_settings().jwt_algorithm + + +def _jwt_access_ttl() -> dt.timedelta: + return dt.timedelta(minutes=get_settings().jwt_access_ttl_minutes) + + +def create_access_token(user_id: uuid.UUID, telegram_id: int) -> str: + """Sign a fresh access JWT for a verified user.""" + settings = get_settings() + now = dt.datetime.now(tz=dt.UTC) + claims = AccessTokenClaims( + sub=user_id, + telegram_id=telegram_id, + type=JWT_TYPE_ACCESS, + ) + payload = claims.to_dict() + payload.update( + { + "iat": int(now.timestamp()), + "exp": int((now + _jwt_access_ttl()).timestamp()), + "iss": settings.otel_service_name or "contract-check", + "aud": "contract-check", + } + ) + token: str = jwt.encode( + payload, + key=_jwt_secret(), + algorithm=_jwt_algorithm(), + ) + return token + + +def verify_access_token(token: str) -> AccessTokenClaims: + """Verify an access JWT and return its claims. + + Raises TokenInvalidError / TokenExpiredError on failure. + """ + try: + payload = jwt.decode( + token, + key=_jwt_secret(), + algorithms=[_jwt_algorithm()], + audience="contract-check", + options={ + "require": ["sub", "telegram_id", "exp", "iat"], + "verify_aud": True, + }, + ) + except jwt.ExpiredSignatureError as exc: + raise TokenExpiredError("token expired") from exc + except jwt.InvalidTokenError as exc: + raise TokenInvalidError("invalid token") from exc + + if payload.get("type") != JWT_TYPE_ACCESS: + raise TokenInvalidError("unexpected token type") + + try: + return AccessTokenClaims.from_dict(payload) + except (KeyError, ValueError, TypeError) as exc: + raise TokenInvalidError("malformed token claims") from exc + + +def _telegram_secret_key(bot_token: str) -> bytes: + """Telegram uses HMAC_SHA256(BOT_TOKEN, 'WebAppData') as the signing key.""" + return hmac.new( + bot_token.encode("utf-8"), + b"WebAppData", + hashlib.sha256, + ).digest() + + +def _constant_time_compare(a: str, b: str) -> bool: + return secrets.compare_digest(a.encode("utf-8"), b.encode("utf-8")) + + +def verify_telegram_web_payload(payload: dict[str, Any], bot_token: str) -> UserIdentity: + """Verify a Telegram Login Widget callback payload. + + Reference: https://core.telegram.org/widgets/login + """ + if not bot_token: + raise AuthError("telegram_bot_token is not configured") + + received_hash = payload.get("hash") + if not isinstance(received_hash, str) or not received_hash: + raise AuthError("missing hash") + + auth_date = payload.get("auth_date") + if not isinstance(auth_date, (int, str)): + raise AuthError("missing auth_date") + + try: + auth_date_int = int(auth_date) + except ValueError as exc: + raise AuthError("invalid auth_date") from exc + + # Reject payloads older than 24 hours to limit replay window. + now = int(dt.datetime.now(tz=dt.UTC).timestamp()) + if now - auth_date_int > 24 * 60 * 60: + raise AuthError("telegram auth payload expired") + + # Build data-check-string from all fields except hash, sorted by key. + data_check_fields = sorted((k, v) for k, v in payload.items() if k != "hash" and v is not None) + data_check_string = "\n".join(f"{k}={v}" for k, v in data_check_fields) + + expected_hash = hmac.new( + _telegram_secret_key(bot_token), + data_check_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + if not _constant_time_compare(received_hash, expected_hash): + raise AuthError("telegram signature mismatch") + + telegram_id = payload.get("id") + if not isinstance(telegram_id, int) or telegram_id <= 0: + raise AuthError("missing telegram id") + + return UserIdentity(telegram_id=telegram_id) + + +def verify_telegram_miniapp_init_data(init_data: str, bot_token: str) -> UserIdentity: + """Verify Telegram Mini App `initData` and extract the user identity. + + `initData` is a query-string-like string that contains a `hash` parameter + and (optionally) a JSON `user` parameter. Telegram signs the full string + excluding `hash` using HMAC_SHA256(bot_token, "WebAppData"). + + Reference: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app + """ + if not bot_token: + raise AuthError("telegram_bot_token is not configured") + + if not isinstance(init_data, str) or "=" not in init_data: + raise AuthError("invalid init_data") + + params = dict(parse_qsl(init_data, keep_blank_values=True)) + received_hash = params.pop("hash", None) + if not received_hash: + raise AuthError("missing hash") + + data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(params.items())) + expected_hash = hmac.new( + _telegram_secret_key(bot_token), + data_check_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + if not _constant_time_compare(received_hash, expected_hash): + raise AuthError("telegram signature mismatch") + + user_json = params.get("user") + if not user_json: + raise AuthError("missing user in init_data") + + import json + + try: + user = json.loads(user_json) + except json.JSONDecodeError as exc: + raise AuthError("invalid user json") from exc + + telegram_id = user.get("id") + if not isinstance(telegram_id, int) or telegram_id <= 0: + raise AuthError("missing telegram id") + + return UserIdentity(telegram_id=telegram_id) + + +def verify_bot_identity(telegram_id: int) -> UserIdentity: + """Identity proof used by the trusted bot adapter. + + The bot receives `message.from_user.id` directly from Telegram; here we just + validate it is a positive integer. The API caller (the bot) is authenticated + separately via its service token. + """ + if not isinstance(telegram_id, int) or telegram_id <= 0: + raise AuthError("invalid telegram_id") + return UserIdentity(telegram_id=telegram_id) diff --git a/src/contract_check/core/config.py b/src/contract_check/core/config.py new file mode 100644 index 0000000..3cfc0a9 --- /dev/null +++ b/src/contract_check/core/config.py @@ -0,0 +1,123 @@ +"""Typed configuration via pydantic-settings (12-factor). + +Base `Settings` carries everything shared across services. A service imports +this and reads only the fields it needs. All values come from environment +variables (or `.env`); nothing is hardcoded. + +Per-service entrypoints may subclass `Settings` to add their own fields +(e.g. the bot adds `BOT_TOKEN`, `API_URL`). See docs/ARCHITECTURE.md §11 for the +full env reference. +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +Env = Literal["dev", "staging", "prod"] +RefundPolicy = Literal["all", "infra_only"] + + +class Settings(BaseSettings): + """Application configuration. See docs/ARCHITECTURE.md §11.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + # --- runtime --- + env: Env = "dev" + log_level: str = "INFO" + + # --- datastores / brokers --- + database_url: str = Field(..., description="async DSN: postgresql+asyncpg://...") + redis_url: str = "redis://redis:6379/0" + rabbitmq_url: str = Field(..., description="amqp://user:pass@host:5672//") + + # --- RabbitMQ tuning --- + mq_prefetch_extract: int = 1 + mq_prefetch_analyze: int = 3 + mq_max_attempts: int = 5 + mq_retry_base_ms: int = 2000 + + # --- object storage (MinIO) --- + s3_endpoint_url: str + s3_access_key: str + s3_secret_key: str + s3_bucket: str = "contract-check-docs" + s3_region: str = "us-east-1" + s3_server_side_encryption: bool = False + doc_retention_days: int = 7 + text_retention_days: int = 30 + + # --- billing --- + refund_policy: RefundPolicy = "all" + + # --- observability (empty disables) --- + sentry_dsn: str = "" + otel_exporter_otlp_endpoint: str = "" + otel_service_name: str = "contract-check" + + # --- LLM provider --- + llm_provider: str = "ollama_cloud" + ollama_host: str = "" + ollama_api_key: str = "" + ollama_model: str = "qwen2.5:14b" + ollama_fallback_model: str = "qwen2.5:7b" + ollama_temperature: float = 0.2 + ollama_num_predict: int = 3072 + ollama_timeout: float = 120.0 + ollama_max_concurrency: int = 3 + + # --- API --- + api_host: str = "0.0.0.0" + api_port: int = 8000 + api_metrics_port: int = 9100 + b2b_default_rate_limit_rps: int = 3 + + # --- auth (JWT + Telegram identity verification) --- + telegram_bot_token: str = Field( + "", description="Telegram bot token; used to verify Login Widget / Mini App signatures" + ) + jwt_secret: str = Field(..., description="HS256 secret for signing user JWTs") + jwt_algorithm: str = "HS256" + jwt_access_ttl_minutes: int = 24 * 60 # 24 hours default; tune per env + + # --- logging --- + log_format: str = "json" # json | console + + # --- analysis --- + chunk_size_chars: int = 10000 + + @property + def json_logs(self) -> bool: + """JSON logs in staging/prod, pretty console in dev. + + Override with LOG_FORMAT=json|console. + """ + fmt = self.log_format.lower() + if fmt == "console": + return False + if fmt == "json": + return True + return self.env != "dev" + + @property + def log_format_value(self) -> str: + """Resolved log format: json in prod/staging unless explicitly console.""" + fmt = self.log_format.lower() + if fmt in ("json", "console"): + return fmt + return "json" if self.env != "dev" else "console" + + +@lru_cache +def get_settings() -> Settings: + """Cached settings singleton. Call `get_settings.cache_clear()` to reset.""" + return Settings() # type: ignore[call-arg] diff --git a/src/contract_check/core/credits.py b/src/contract_check/core/credits.py new file mode 100644 index 0000000..292e965 --- /dev/null +++ b/src/contract_check/core/credits.py @@ -0,0 +1,84 @@ +"""Credits & refund policy — billing invariants. + +Reserve-on-enqueue is sacred: a credit moves ONLY on `POST /documents` in the +api, atomically (never below zero). Refund is sacred: idempotent via the +`documents.refunded` flag (a retried/DLQ message can never double-refund). + +`refund_credit` honours the runtime policy `REFUND_POLICY`: + - "all" → refund on any terminal failure + - "infra_only" → refund everything EXCEPT user-garbage input (extraction_failed) +See docs/ARCHITECTURE.md §8. Callers own the transaction (commit after these return). +""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from .db.enums import FailureClass, RefundPolicyLike + +NON_REFUNDABLE_INFRA_ONLY: frozenset[str] = frozenset({"extraction_failed"}) + + +def should_refund(failure_class: FailureClass | str, policy: RefundPolicyLike) -> bool: + """Pure policy decision: would this failure be refunded under `policy`? + + Under `infra_only`, user-garbage `extraction_failed` is NOT refunded + (the user pays for undetectable garbage). Everything else is. + """ + if policy == "infra_only" and failure_class in NON_REFUNDABLE_INFRA_ONLY: + return False + return True + + +async def reserve_credit(session: AsyncSession, user_id: uuid.UUID) -> bool: + """Atomically decrement credits_left by 1. Returns False if none available. + + Never lets the balance go negative: the `WHERE credits_left > 0` guard makes + concurrent reservations race-safe — exactly one of N wins the row. + """ + result = await session.execute( + text( + "UPDATE users SET credits_left = credits_left - 1 " + "WHERE id = :u AND credits_left > 0 " + "RETURNING credits_left" + ), + {"u": user_id}, + ) + return result.first() is not None + + +async def refund_credit( + session: AsyncSession, + document_id: uuid.UUID, + failure_class: FailureClass | str, + policy: RefundPolicyLike, +) -> bool: + """Refund one credit for a failed document, once. Returns False if skipped. + + Idempotent: a second call (retried message, requeue) finds `refunded = TRUE` + and returns False without crediting again. Under `infra_only`, user-garbage + `extraction_failed` is NOT refunded (the user pays for undetectable garbage). + """ + if not should_refund(failure_class, policy): + return False + + result = await session.execute( + text( + "UPDATE users SET credits_left = credits_left + 1 " + "WHERE id = (SELECT user_id FROM documents " + " WHERE id = :d AND refunded = FALSE) " + "RETURNING id" + ), + {"d": document_id}, + ) + if result.first() is None: + return False # already refunded (idempotent) or document missing + + await session.execute( + text("UPDATE documents SET refunded = TRUE WHERE id = :d"), + {"d": document_id}, + ) + return True diff --git a/src/contract_check/core/db/__init__.py b/src/contract_check/core/db/__init__.py new file mode 100644 index 0000000..2c45e83 --- /dev/null +++ b/src/contract_check/core/db/__init__.py @@ -0,0 +1,5 @@ +"""Database layer — SQLAlchemy 2 models (6 tables), async session, status enums. + +See docs/ARCHITECTURE.md §7 for the full schema and rationale. status/queue/adapter +columns are TEXT + CHECK (not Postgres enums) so migrations stay additive. +""" diff --git a/src/contract_check/core/db/enums.py b/src/contract_check/core/db/enums.py new file mode 100644 index 0000000..ea397b3 --- /dev/null +++ b/src/contract_check/core/db/enums.py @@ -0,0 +1,67 @@ +"""Status / class string constants (stored as TEXT+CHECK in Postgres). + +Kept as plain Python tuples + Literal aliases (not Postgres ENUMs) so every +migration is additive — matches the convention in docs/IMPLEMENTATION_PLAN.md §1.2. +""" + +from __future__ import annotations + +from typing import Literal + +# documents.status +DocStatus = Literal["queued", "extracting", "ocr", "analyzing", "done", "failed"] +DOC_STATUSES: tuple[str, ...] = ( + "queued", + "extracting", + "ocr", + "analyzing", + "done", + "failed", +) +DOC_TERMINAL: tuple[str, ...] = ("done", "failed") + +# jobs.status +JobStatus = Literal["pending", "running", "retrying", "dlq", "done"] +JOB_STATUSES: tuple[str, ...] = ("pending", "running", "retrying", "dlq", "done") + +# jobs.queue +QueueName = Literal["extract", "analyze"] +QUEUE_NAMES: tuple[str, ...] = ("extract", "analyze") + +# service_tokens.adapter +AdapterName = Literal["bot", "web", "cli"] +ADAPTER_NAMES: tuple[str, ...] = ("bot", "web", "cli") + +# jobs.last_failure_class — drives the refund policy (see core/credits.py) +FailureClass = Literal[ + "extraction_failed", + "ocr_failed", + "llm_quota", + "llm_invalid_output", + "llm_timeout", + "infra", + "unknown", +] +FAILURE_CLASSES: tuple[str, ...] = ( + "extraction_failed", + "ocr_failed", + "llm_quota", + "llm_invalid_output", + "llm_timeout", + "infra", + "unknown", +) + +# invoices.status (stub table — no ЮKassa logic this refactor) +InvoiceStatus = Literal["draft", "pending", "succeeded", "cancelled", "refunded"] +INVOICE_STATUSES: tuple[str, ...] = ( + "draft", + "pending", + "succeeded", + "cancelled", + "refunded", +) + +# refund policy switch (REFUND_POLICY env) +RefundPolicyLike = Literal["all", "infra_only"] +REFUND_POLICIES: tuple[str, ...] = ("all", "infra_only") diff --git a/src/contract_check/core/db/models.py b/src/contract_check/core/db/models.py new file mode 100644 index 0000000..d6e59f1 --- /dev/null +++ b/src/contract_check/core/db/models.py @@ -0,0 +1,333 @@ +"""SQLAlchemy 2 declarative models — the 6 production tables. + +Tables: users, documents, reports, jobs, service_tokens, invoices (stub). +See docs/ARCHITECTURE.md §7. UUIDs default to gen_random_uuid() server-side +(built into Postgres 13+, no extension needed for pg16). +""" + +from __future__ import annotations + +import datetime as dt +import uuid + +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, + func, + text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + """Declarative base for all models.""" + + +def _now() -> dt.datetime: + return dt.datetime.now(tz=dt.UTC) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + credits_left: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + __table_args__ = (CheckConstraint("credits_left >= 0", name="users_credits_nonneg"),) + + documents: Mapped[list[Document]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + api_keys: Mapped[list[ApiKey]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + + +class Document(Base): + __tablename__ = "documents" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + s3_key: Mapped[str] = mapped_column(Text, nullable=False) + extracted_s3_key: Mapped[str | None] = mapped_column(Text) + filename: Mapped[str] = mapped_column(Text, nullable=False) + mime: Mapped[str] = mapped_column(Text, nullable=False) + bytes_: Mapped[int] = mapped_column("bytes", BigInteger, nullable=False, default=0) + status: Mapped[str] = mapped_column( + String, + nullable=False, + default="queued", + server_default=text("'queued'"), + ) + stage: Mapped[str | None] = mapped_column(Text) + refunded: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=text("false") + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=_now + ) + + user: Mapped[User] = relationship(back_populates="documents") + report: Mapped[Report | None] = relationship( + back_populates="document", cascade="all, delete-orphan", uselist=False + ) + jobs: Mapped[list[Job]] = relationship(back_populates="document", cascade="all, delete-orphan") + api_key_requests: Mapped[list[ApiKeyRequest]] = relationship( + back_populates="document", cascade="all, delete-orphan" + ) + + __table_args__ = ( + CheckConstraint( + "status IN ('queued','extracting','ocr','analyzing','done','failed')", + name="documents_status_check", + ), + Index("documents_user_created_idx", "user_id", "created_at"), + Index("documents_status_idx", "status"), + ) + + +class Report(Base): + __tablename__ = "reports" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + document_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("documents.id", ondelete="CASCADE"), + nullable=False, + unique=True, + ) + content_json: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) + markdown: Mapped[str] = mapped_column(Text, nullable=False) + model_used: Mapped[str | None] = mapped_column(Text) + prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + eval_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + document: Mapped[Document] = relationship(back_populates="report") + + +class Job(Base): + __tablename__ = "jobs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + document_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("documents.id", ondelete="CASCADE"), + nullable=False, + ) + correlation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + queue: Mapped[str] = mapped_column(String, nullable=False) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5) + last_failure_class: Mapped[str | None] = mapped_column(Text) + last_error: Mapped[str | None] = mapped_column(Text) + dlq: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=text("false") + ) + status: Mapped[str] = mapped_column( + String, + nullable=False, + default="pending", + server_default=text("'pending'"), + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=_now + ) + + document: Mapped[Document] = relationship(back_populates="jobs") + + __table_args__ = ( + CheckConstraint("queue IN ('extract','analyze')", name="jobs_queue_check"), + CheckConstraint( + "status IN ('pending','running','retrying','dlq','done')", + name="jobs_status_check", + ), + Index("jobs_correlation_idx", "correlation_id"), + Index("jobs_document_idx", "document_id"), + ) + + +class ServiceToken(Base): + __tablename__ = "service_tokens" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + name: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + token_hash: Mapped[str] = mapped_column(Text, nullable=False) + adapter: Mapped[str] = mapped_column(String, nullable=False) + revoked: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=text("false") + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + last_used_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) + + __table_args__ = ( + CheckConstraint("adapter IN ('bot','web','cli')", name="service_tokens_adapter_check"), + ) + + +class ApiKey(Base): + """B2B API key: opaque bearer secret hashed via SHA-256.""" + + __tablename__ = "api_keys" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + name: Mapped[str] = mapped_column(Text, nullable=False) + key_hash: Mapped[str] = mapped_column(Text, nullable=False) + rate_limit_rps: Mapped[int] = mapped_column( + Integer, nullable=False, default=3, server_default=text("3") + ) + monthly_quota: Mapped[int | None] = mapped_column(Integer) + monthly_used: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default=text("0") + ) + resets_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + revoked: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=text("false") + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + last_used_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) + + user: Mapped[User] = relationship(back_populates="api_keys") + requests: Mapped[list[ApiKeyRequest]] = relationship( + back_populates="api_key", cascade="all, delete-orphan" + ) + + __table_args__ = ( + CheckConstraint("rate_limit_rps > 0", name="api_keys_rate_limit_positive"), + CheckConstraint("monthly_used >= 0", name="api_keys_monthly_used_nonneg"), + CheckConstraint( + "monthly_quota IS NULL OR monthly_quota >= 0", + name="api_keys_monthly_quota_nonneg", + ), + Index("api_keys_user_idx", "user_id"), + Index("api_keys_hash_idx", "key_hash"), + UniqueConstraint("user_id", "name", name="api_keys_user_name_unique"), + ) + + +class ApiKeyRequest(Base): + """One B2B API call = one request row for usage/dashboard.""" + + __tablename__ = "api_key_requests" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + api_key_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("api_keys.id", ondelete="CASCADE"), + nullable=False, + ) + document_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("documents.id", ondelete="CASCADE"), + nullable=False, + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + api_key: Mapped[ApiKey] = relationship(back_populates="requests") + document: Mapped[Document] = relationship(back_populates="api_key_requests") + + __table_args__ = ( + Index("api_key_requests_key_created_idx", "api_key_id", text("created_at DESC")), + ) + + +class Invoice(Base): + """STUB — schema forward-compatible for ЮKassa. No payment logic yet.""" + + __tablename__ = "invoices" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + amount: Mapped[int] = mapped_column(Integer, nullable=False) # kopecks + status: Mapped[str] = mapped_column( + String, nullable=False, default="draft", server_default=text("'draft'") + ) + provider: Mapped[str | None] = mapped_column(Text) + external_id: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + paid_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) + + __table_args__ = ( + CheckConstraint( + "status IN ('draft','pending','succeeded','cancelled','refunded')", + name="invoices_status_check", + ), + Index("invoices_user_idx", "user_id", "created_at"), + ) diff --git a/src/contract_check/core/db/session.py b/src/contract_check/core/db/session.py new file mode 100644 index 0000000..082daa9 --- /dev/null +++ b/src/contract_check/core/db/session.py @@ -0,0 +1,43 @@ +"""Async engine + session factory. + +All services share the same engine construction. The api uses +`get_session()` as a FastAPI dependency (wired in Step 2); workers build +sessions directly from the factory. See docs/ARCHITECTURE.md §7. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from ..config import get_settings + + +def create_engine(url: str | None = None, **kwargs: Any) -> AsyncEngine: + """Build an async engine from DATABASE_URL (or an explicit url).""" + url = url or get_settings().database_url + return create_async_engine(url, pool_pre_ping=True, **kwargs) + + +def create_session_factory( + engine: AsyncEngine | None = None, +) -> async_sessionmaker[AsyncSession]: + """Build an async_sessionmaker bound to `engine` (or a fresh one).""" + engine = engine or create_engine() + return async_sessionmaker(engine, expire_on_commit=False) + + +async def get_session( + factory: async_sessionmaker[AsyncSession] | None = None, +) -> AsyncIterator[AsyncSession]: + """FastAPI/yield-style session dependency.""" + factory = factory or create_session_factory() + async with factory() as session: + yield session diff --git a/src/contract_check/core/errors.py b/src/contract_check/core/errors.py new file mode 100644 index 0000000..67e5f48 --- /dev/null +++ b/src/contract_check/core/errors.py @@ -0,0 +1,17 @@ +"""Shared exception markers used across service boundaries. + +These are intentionally lightweight and cross-cutting so that a RabbitMQ +consumer (core/mq) can recognize a terminal failure raised by an adapter +(core/llm) without creating a circular dependency. +""" + +from __future__ import annotations + + +class TerminalError(Exception): + """Raised for failures that should not be retried by the MQ consumer. + + Subclasses indicate a permanent or configuration-level problem (e.g. an + unreachable host, missing model, or invalid credentials). The consumer will + route the message straight to the DLQ instead of retrying. + """ diff --git a/src/contract_check/core/llm/__init__.py b/src/contract_check/core/llm/__init__.py new file mode 100644 index 0000000..197a7d6 --- /dev/null +++ b/src/contract_check/core/llm/__init__.py @@ -0,0 +1,12 @@ +"""LLM provider layer — abstract port + adapters. + +The first and only realization is Ollama Cloud (ported from the stage-0 +prototype). Future providers (self-hosted Ollama, GigaChat, YandexGPT) plug +in here without touching analysis code. See docs/ARCHITECTURE.md §9. +""" + +from __future__ import annotations + +from .port import AnalysisResult, LLMProvider + +__all__ = ["LLMProvider", "AnalysisResult"] diff --git a/src/contract_check/core/llm/factory.py b/src/contract_check/core/llm/factory.py new file mode 100644 index 0000000..3638fe5 --- /dev/null +++ b/src/contract_check/core/llm/factory.py @@ -0,0 +1,32 @@ +"""LLM provider factory — selects an adapter by `LLM_PROVIDER` (env). + +Add new providers here (self-hosted Ollama, GigaChat, YandexGPT) and register +a case. The analysis code never changes. +""" + +from __future__ import annotations + +from ..config import Settings +from .ollama_cloud import OllamaCloudProvider +from .port import LLMProvider + + +def build_llm_provider(settings: Settings) -> LLMProvider: + """Build the configured LLM provider. Defaults to Ollama Cloud.""" + match settings.llm_provider: + case "ollama_cloud": + return OllamaCloudProvider( + host=settings.ollama_host, + api_key=settings.ollama_api_key, + model=settings.ollama_model, + fallback_model=settings.ollama_fallback_model or None, + temperature=settings.ollama_temperature, + num_predict=settings.ollama_num_predict, + timeout=settings.ollama_timeout, + max_concurrency=settings.ollama_max_concurrency, + chunk_size=settings.chunk_size_chars, + ) + case _: + raise ValueError( + f"unknown LLM_PROVIDER={settings.llm_provider!r} (expected: 'ollama_cloud')" + ) diff --git a/src/contract_check/core/llm/ollama_cloud.py b/src/contract_check/core/llm/ollama_cloud.py new file mode 100644 index 0000000..ce45c12 --- /dev/null +++ b/src/contract_check/core/llm/ollama_cloud.py @@ -0,0 +1,465 @@ +"""Ollama Cloud adapter — first realization of LLMProvider. + +Ported from the stage-0 prototype `llm_client.py` (bearer httpx, `format: +json-schema`, 5xx backoff, 429→fallback, pydantic repair-loop) and wrapped to +implement `LLMProvider.analyze(text, *, checklist)` with chunk fan-out, +de-duplication, and severity sort. The worker maps the raised exceptions to a +FailureClass (see core/mq/consumer.py + worker_analyze/handler.py). +""" + +from __future__ import annotations + +import asyncio +import json +import time +from dataclasses import dataclass +from types import TracebackType +from typing import Any, Self + +import httpx +from pydantic import BaseModel, ValidationError + +from ..analysis.analyzer import build_user_prompt, dedupe_findings, sort_findings +from ..analysis.checklist import checklist_for_prompt +from ..analysis.chunker import chunk_text +from ..analysis.report_schema import ReportPayload +from ..errors import TerminalError +from ..logging import get_logger +from .port import AnalysisResult + +log = get_logger(__name__) + +# How much of a payload/response we keep in log lines. Full contracts can be +# large; we log enough to debug schema/JSON issues without spamming or leaking +# entire documents at info/warning level. +_LOG_MAX_PAYLOAD_CHARS = 1200 + +REPAIR_SYSTEM = ( + "Твой предыдущий ответ не прошёл валидацию JSON-схемы. " + "Верни СТРОГО валидный JSON, соответствующий схеме, без пояснений и markdown. " + "Цитаты — дословные из исходного текста договора." +) + +SYSTEM_PROMPT = ( + "Ты — помощник первичного юридического скрининга договоров по праву " + "РФ / РБ (ГК РФ, ГК РБ). Твоя задача — найти РИСКИ для стороны, которая " + "обращается за анализом (считай её «нашим клиентом»), по чек-листу ниже.\n\n" + "Жёсткие правила:\n" + "1. Анализируй ТОЛЬКО текст договора в этом сообщении. Не придумывай пункты.\n" + "2. На каждую находку ОБЯЗАТЕЛЬНО: дословная цитата из оригинала (поле quote) " + "и номер/название пункта договора (поле section_ref), чтобы сверить одним кликом.\n" + "3. Если по какому-то пункту чек-листа риска нет — НЕ включай его в findings.\n" + "4. severity: high — существенный риск (штрафы, потеря прав, чужая подсудность); " + "medium — стоит уточнить; low — мелочь на заметку.\n" + "5. Возвращай ТОЛЬКО JSON по схеме, без markdown и пояснений вне JSON.\n\n" + "Чек-лист пунктов анализа:\n{checklist}" +) + + +class LLMError(Exception): + """Unrecoverable LLM failure (after all retries).""" + + +class LLMQuotaError(LLMError): + """429 / quota on both primary and fallback — refundable failure.""" + + +class LLMUnavailableError(LLMError): + """Ollama server unreachable or returns non-200 status — retryable.""" + + +class LLMConfigError(LLMError, TerminalError): + """Misconfigured Ollama host/model/endpoint — terminal, do not retry.""" + + +class _QuotaSignal(Exception): + """Internal: 429 triggers fallback within the same call.""" + + +@dataclass(slots=True) +class _ChatResult: + data: BaseModel + model_used: str + fell_back: bool + repaired: bool + attempts: int + prompt_tokens: int + eval_tokens: int + latency_sec: float + + +class OllamaCloudProvider: + """LLMProvider backed by Ollama Cloud (hosted open models).""" + + def __init__( + self, + *, + host: str, + api_key: str, + model: str, + fallback_model: str | None = None, + temperature: float = 0.2, + num_predict: int = 3072, + timeout: float = 120.0, + max_concurrency: int = 3, + chunk_size: int = 10000, + ) -> None: + self._chunk_size = chunk_size + self._model = model + self._fallback = fallback_model or None + self._temperature = temperature + self._num_predict = num_predict + self._retries = 3 + self._sem = asyncio.Semaphore(max(1, max_concurrency)) + self._client = httpx.AsyncClient( + base_url=host.rstrip("/"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + timeout=httpx.Timeout(timeout, connect=10.0), + ) + + async def aclose(self) -> None: + await self._client.aclose() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() + + # ── public: LLMProvider.analyze ────────────────────────────────────────── + async def analyze(self, text: str, *, checklist: str | None = None) -> AnalysisResult: + checklist = checklist or checklist_for_prompt() + chunks = chunk_text(text, max_chars=self._chunk_size) + system_msg = SYSTEM_PROMPT.format(checklist=checklist) + + async def one(chunk: str) -> _ChatResult: + async with self._sem: + return await self._run_with_fallback( + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": build_user_prompt(chunk)}, + ], + schema_model=ReportPayload, + ) + + results = await asyncio.gather(*(one(c) for c in chunks)) + + findings = [] + prompt_tokens = eval_tokens = 0 + latency = 0.0 + models_used: set[str] = set() + fell_back = repaired = False + for r in results: + payload: ReportPayload = r.data # type: ignore[assignment] + findings.extend(payload.findings) + prompt_tokens += r.prompt_tokens + eval_tokens += r.eval_tokens + latency = max(latency, r.latency_sec) + models_used.add(r.model_used) + fell_back |= r.fell_back + repaired |= r.repaired + + findings = sort_findings(dedupe_findings(findings)) + return AnalysisResult( + findings=findings, + model_used=", ".join(sorted(models_used)), + fell_back=fell_back, + repaired=repaired, + prompt_tokens=prompt_tokens, + eval_tokens=eval_tokens, + latency_sec=latency, + models_used=models_used, + ) + + # ── internals (ported from prototype llm_client.py) ───────────────────── + async def _run_with_fallback( + self, + messages: list[dict[str, str]], + schema_model: type[BaseModel], + ) -> _ChatResult: + schema = schema_model.model_json_schema() + try: + return await self._run_repair_loop(messages, schema_model, schema, self._model) + except _QuotaSignal: + if self._fallback and self._model != self._fallback: + log.warning("quota_429_fallback", primary=self._model, fallback=self._fallback) + return await self._run_repair_loop(messages, schema_model, schema, self._fallback) + raise LLMQuotaError(f"429/quota on {self._model} and no usable fallback") from None + + async def _run_repair_loop( + self, + messages: list[dict[str, str]], + schema_model: type[BaseModel], + schema: dict[str, Any], + model: str, + ) -> _ChatResult: + started = time.perf_counter() + attempts = 0 + repaired = False + current = list(messages) + while True: + attempts += 1 + content, usage = await self._post_chat(current, model, schema) + cleaned = _strip_markdown_fences(content) + try: + data = _parse_and_validate(cleaned, schema_model) + except (ValidationError, ValueError) as exc: + error_summary = _format_parse_error(exc) + log.warning( + "invalid_json_repair", + model=model, + error=type(exc).__name__, + attempt=attempts, + content_preview=cleaned[:_LOG_MAX_PAYLOAD_CHARS], + content_type=type(content).__name__, + content_len=len(content), + **error_summary, + ) + if attempts <= 1: + repaired = True + current = [ + {"role": "system", "content": REPAIR_SYSTEM}, + {"role": "user", "content": messages[-1]["content"]}, + {"role": "assistant", "content": cleaned[:4000]}, + {"role": "user", "content": "Верни только валидный JSON по схеме."}, + ] + continue + raise LLMError(f"Model {model} returned invalid JSON after repair: {exc}") from exc + return _ChatResult( + data=data, + model_used=model, + fell_back=(model != self._model), + repaired=repaired, + attempts=attempts, + prompt_tokens=usage[0], + eval_tokens=usage[1], + latency_sec=time.perf_counter() - started, + ) + + async def _post_chat( + self, + messages: list[dict[str, str]], + model: str, + schema: dict[str, Any], + ) -> tuple[str, tuple[int, int]]: + payload = { + "model": model, + "messages": messages, + "format": schema, + "stream": False, + "options": { + "temperature": self._temperature, + "num_predict": self._num_predict, + }, + } + log.debug( + "ollama_request", + model=model, + messages=_safe_messages(messages, _LOG_MAX_PAYLOAD_CHARS), + schema=_safe_schema(schema), + ) + last_exc: Exception | None = None + for attempt in range(1, self._retries + 1): + try: + resp = await self._client.post("/api/chat", json=payload) + except httpx.ConnectError as exc: + # ConnectError means the configured host is not listening. + # Retrying will not fix a wrong host, so fail terminal immediately. + raise LLMConfigError( + f"Cannot connect to Ollama server at configured host: {exc}. " + "Verify OLLAMA_HOST points to a running Ollama server." + ) from exc + except httpx.TimeoutException as exc: + last_exc = exc + log.warning( + "ollama_timeout", + model=model, + attempt=attempt, + error=f"{type(exc).__name__}: {exc}", + request_messages=_safe_messages(messages, _LOG_MAX_PAYLOAD_CHARS), + ) + if attempt < self._retries: + await _backoff(attempt) + continue + raise LLMUnavailableError( + f"Ollama request timed out after {self._retries} attempts: {exc}" + ) from exc + except httpx.HTTPError as exc: + last_exc = exc + log.warning( + "ollama_http_error", + model=model, + attempt=attempt, + error=f"{type(exc).__name__}: {exc}", + request_messages=_safe_messages(messages, _LOG_MAX_PAYLOAD_CHARS), + ) + if attempt < self._retries: + await _backoff(attempt) + continue + raise LLMUnavailableError( + f"HTTP error calling Ollama after {self._retries} attempts: {exc}" + ) from exc + + if resp.status_code == 429: + raise _QuotaSignal(f"429 Too Many Requests (model={model})") + if resp.status_code >= 500: + if attempt < self._retries: + log.warning( + "http_5xx_retry", + status=resp.status_code, + model=model, + attempt=attempt, + response_text=resp.text[:500], + ) + await _backoff(attempt) + continue + raise LLMUnavailableError(f"HTTP {resp.status_code} from Ollama: {resp.text[:500]}") + if resp.status_code == 404: + raise LLMConfigError( + f"Ollama endpoint not found (404): {resp.text[:500]}. " + "Verify OLLAMA_HOST points to a running Ollama server." + ) + if resp.status_code >= 400: + raise LLMError(f"HTTP {resp.status_code} from Ollama: {resp.text[:500]}") + + try: + body = resp.json() + except Exception as exc: + log.error( + "ollama_response_not_json", + model=model, + response_text=resp.text[:_LOG_MAX_PAYLOAD_CHARS], + error=f"{type(exc).__name__}: {exc}", + ) + raise LLMError(f"Invalid JSON response from Ollama: {exc}") from exc + + raw_content = body.get("message", {}).get("content", "") + content = _normalize_content(raw_content).strip() + prompt_tokens = int(body.get("prompt_eval_count", 0) or 0) + eval_tokens = int(body.get("eval_count", 0) or 0) + if not content: + log.error( + "ollama_empty_content", + model=model, + raw_content_type=type(raw_content).__name__, + body=_safe_body(body, _LOG_MAX_PAYLOAD_CHARS), + ) + raise LLMError("Empty message content from Ollama") + + log.debug( + "ollama_response", + model=model, + content=content[:_LOG_MAX_PAYLOAD_CHARS], + prompt_tokens=prompt_tokens, + eval_tokens=eval_tokens, + ) + return content, (prompt_tokens, eval_tokens) + + raise LLMUnavailableError(f"Exhausted retries: {last_exc}") + + +def _parse_and_validate(content: str, schema_model: type[BaseModel]) -> BaseModel: + data = json.loads(content) + return schema_model.model_validate(data) + + +def _safe_messages(messages: list[dict[str, str]], max_chars: int) -> list[dict[str, str]]: + """Return a copy of messages with long contents truncated for logging.""" + out: list[dict[str, str]] = [] + for msg in messages: + content = msg.get("content", "") + out.append( + { + "role": msg.get("role", "unknown"), + "content": content[:max_chars] + ("..." if len(content) > max_chars else ""), + } + ) + return out + + +def _safe_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Return a schema safe for logs: title/properties preserved, no full examples.""" + # Schema objects are small; log as-is but drop any possible long examples. + safe: dict[str, Any] = { + k: v for k, v in schema.items() if k not in ("examples", "$defs", "definitions") + } + # Limit property list for very large schemas. + properties = safe.get("properties") + if isinstance(properties, dict) and len(properties) > 30: + safe["properties"] = {k: properties[k] for k in list(properties.keys())[:30]} + safe["_properties_truncated"] = True + return safe + + +def _safe_body(body: dict[str, Any], max_chars: int) -> dict[str, Any]: + """Return a copy of the Ollama chat response body with long strings truncated.""" + safe: dict[str, Any] = {} + for k, v in body.items(): + if isinstance(v, bytes): + safe[k] = _normalize_content(v)[:max_chars] + elif isinstance(v, str): + safe[k] = v[:max_chars] + ("..." if len(v) > max_chars else "") + elif isinstance(v, dict): + safe[k] = _safe_body(v, max_chars) + else: + safe[k] = v + return safe + + +def _normalize_content(value: object) -> str: + """Turn whatever Ollama put in message.content into a clean str. + + Ollama Cloud occasionally returns ``bytes`` instead of ``str``. + ``str(bytes)`` produces a Python repr like "b'...'" which breaks JSON + parsing. We decode UTF-8 (with fallback) and keep plain strings untouched. + """ + if isinstance(value, str): + return value + if isinstance(value, bytes): + try: + return value.decode("utf-8") + except UnicodeDecodeError: + return value.decode("utf-8", errors="replace") + if value is None: + return "" + return str(value) + + +def _strip_markdown_fences(text: str) -> str: + """Remove ```json ... ``` or ``` ... ``` wrappers that models often emit.""" + stripped = text.strip() + if stripped.startswith("```"): + # Drop the opening fence (possibly with 'json' label). + first_newline = stripped.find("\n") + if first_newline != -1: + stripped = stripped[first_newline + 1 :] + else: + stripped = stripped[3:] + stripped = stripped.rstrip() + if stripped.endswith("```"): + stripped = stripped[:-3].rstrip() + return stripped + + +def _format_parse_error(exc: BaseException) -> dict[str, object]: + """Return a small, log-safe summary of a JSON/Pydantic parse failure.""" + if isinstance(exc, ValidationError): + return { + "validation_errors": [ + {"loc": list(e.get("loc", [])), "msg": e.get("msg", ""), "type": e.get("type", "")} + for e in exc.errors() + ], + } + return {"parse_error": str(exc)[:500]} + + +async def _backoff(attempt: int) -> None: + await asyncio.sleep(min(8.0, 0.5 * (2 ** (attempt - 1)))) diff --git a/src/contract_check/core/llm/port.py b/src/contract_check/core/llm/port.py new file mode 100644 index 0000000..ffff09a --- /dev/null +++ b/src/contract_check/core/llm/port.py @@ -0,0 +1,34 @@ +"""LLM provider port + AnalysisResult contract. + +`analyze(text, *, checklist)` returns the merged, de-duplicated, severity- +sorted findings for a whole contract (the provider handles chunking + fan-out +internally). Failures bubble as exceptions the worker classifies into a +FailureClass for the refund policy. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +from ..analysis.report_schema import Finding + + +@dataclass(slots=True) +class AnalysisResult: + findings: list[Finding] + model_used: str = "" + fell_back: bool = False + repaired: bool = False + prompt_tokens: int = 0 + eval_tokens: int = 0 + latency_sec: float = 0.0 + # aggregate metrics; populated by adapters + models_used: set[str] = field(default_factory=set) + + +@runtime_checkable +class LLMProvider(Protocol): + async def analyze(self, text: str, *, checklist: str) -> AnalysisResult: ... + + async def aclose(self) -> None: ... diff --git a/src/contract_check/core/logging.py b/src/contract_check/core/logging.py new file mode 100644 index 0000000..4e183fc --- /dev/null +++ b/src/contract_check/core/logging.py @@ -0,0 +1,231 @@ +"""Structured logging — structlog JSON + correlation_id propagation. + +The correlation_id is the spine of every log line in the event-driven system. +It is set: + - in the api by middleware (from `X-Correlation-ID` header or minted), + - in workers by the consumer base (from the RabbitMQ message header + `x-correlation-id`), +so a single upload's logs trace api → rabbit → worker-extract → +worker-analyze → DB under one id. + +Additional global context (service, env, version) is bound at startup and +inherited by every logger. Per-request/job attributes can be added with +`bind_context(**extra)`. +""" + +from __future__ import annotations + +import contextvars +import logging +import os +import sys +import traceback +import uuid + +import structlog +from structlog.stdlib import BoundLogger +from structlog.types import EventDict, WrappedLogger + +correlation_id_var: contextvars.ContextVar[str] = contextvars.ContextVar( + "correlation_id", default="" +) +service_var: contextvars.ContextVar[str] = contextvars.ContextVar("service", default="") +env_var: contextvars.ContextVar[str] = contextvars.ContextVar("env", default="") + + +def set_correlation_id(value: str | None) -> None: + """Set the correlation id for the current async context.""" + correlation_id_var.set(value or "") + + +def get_correlation_id() -> str: + """Return the current correlation id ("" if unset).""" + return correlation_id_var.get() + + +def new_correlation_id() -> str: + """Mint a new correlation id and bind it to the current context.""" + cid = str(uuid.uuid4()) + set_correlation_id(cid) + return cid + + +def set_service(value: str | None) -> None: + """Bind the service name to the current logging context.""" + service_var.set(value or "") + + +def set_env(value: str | None) -> None: + """Bind the runtime environment to the current logging context.""" + env_var.set(value or "") + + +def bind_context(**kwargs: object) -> None: + """Bind extra key/value pairs to the current structlog context. + + These appear in every subsequent log line in this context until cleared. + """ + structlog.contextvars.bind_contextvars(**{k: v for k, v in kwargs.items() if v is not None}) + + +def clear_context() -> None: + """Clear all structlog contextvars (use with care, mostly for tests).""" + structlog.contextvars.clear_contextvars() + correlation_id_var.set("") + service_var.set("") + env_var.set("") + + +def _inject_static_context( + _logger: WrappedLogger, _method_name: str, event_dict: EventDict +) -> EventDict: + cid = correlation_id_var.get() + if cid: + event_dict["correlation_id"] = cid + service = service_var.get() + if service: + event_dict["service"] = service + env = env_var.get() + if env: + event_dict["env"] = env + return event_dict + + +def _add_version(_logger: WrappedLogger, _method_name: str, event_dict: EventDict) -> EventDict: + version = os.getenv("APP_VERSION", "unknown") + if version: + event_dict["version"] = version + return event_dict + + +def _format_exc_info(_logger: WrappedLogger, _method_name: str, event_dict: EventDict) -> EventDict: + """Attach a full, structured exception payload when an exception is in flight. + + Unlike the default structlog processor this never truncates the traceback + and always separates exception class, message, and traceback so log parsers + can group by exception_type. + """ + exc_info = event_dict.get("exc_info", False) + if not exc_info: + return event_dict + + if isinstance(exc_info, bool): + exc_info = sys.exc_info() + + if exc_info is None or exc_info == (None, None, None): + event_dict.pop("exc_info", None) + return event_dict + + exc_type, exc_value, exc_tb = exc_info + if exc_value is None: + event_dict.pop("exc_info", None) + return event_dict + + event_dict["exception_type"] = exc_type.__name__ if exc_type else "UnknownException" + event_dict["exception_message"] = str(exc_value) + event_dict["exception_module"] = ( + exc_type.__module__ if exc_type and exc_type.__module__ != "builtins" else None + ) + event_dict["exception_traceback"] = "".join( + traceback.format_exception(exc_type, exc_value, exc_tb) + ) + event_dict["exc_info"] = False + return event_dict + + +def _filter_sensitive_keys( + _logger: WrappedLogger, _method_name: str, event_dict: EventDict +) -> EventDict: + """Redact obvious secrets from log payloads when they leak into keyword args.""" + sensitive = {"api_key", "token", "secret", "password", "authorization"} + for key in event_dict: + if any(s in key.lower() for s in sensitive): + value = event_dict[key] + if isinstance(value, str) and value: + event_dict[key] = value[:4] + "***" + return event_dict + + +def configure_logging( + level: str = "INFO", + *, + json_output: bool = True, + service: str = "contract-check", + env: str = "dev", +) -> None: + """Configure structlog + stdlib logging. + + json_output=True (staging/prod) → JSON renderer; False (dev) → colored console. + service and env are bound globally so every log line carries them. + """ + set_service(service) + set_env(env) + + processors: list[structlog.types.Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso", utc=True), + _inject_static_context, + _add_version, + structlog.processors.StackInfoRenderer(), + _format_exc_info, + _filter_sensitive_keys, + ] + processors.append( + structlog.processors.JSONRenderer(sort_keys=True) + if json_output + else structlog.dev.ConsoleRenderer(colors=True, pad_event=False) + ) + + structlog.configure( + processors=processors, + wrapper_class=structlog.make_filtering_bound_logger(_level_to_int(level)), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(file=sys.stderr), + cache_logger_on_first_use=True, + ) + # Route stdlib logging through structlog so third-party libs match our format. + stdlib_handler = logging.StreamHandler(sys.stderr) + stdlib_handler.setFormatter( + structlog.stdlib.ProcessorFormatter( + processor=processors[-1], + foreign_pre_chain=processors[:-1], + ) + ) + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(stdlib_handler) + root_logger.setLevel(_level_to_int(level)) + + +def _level_to_int(level: str) -> int: + return getattr(logging, level.upper(), logging.INFO) + + +def get_logger(name: str | None = None) -> BoundLogger: + """Return a bound structlog logger.""" + logger = structlog.get_logger(name) + return logger # type: ignore[no-any-return] + + +def is_debug_enabled() -> bool: + """True when DEBUG-level records are emitted under the current configuration. + + structlog's filtering bound logger exposes no ``isEnabledFor``; both it and + the stdlib root logger are set to the same level in :func:`configure_logging`, + so the root logger is the authoritative level source. Call this instead of + touching stdlib ``logging`` from feature code. + """ + return logging.getLogger().isEnabledFor(logging.DEBUG) + + +def log_error( + logger: BoundLogger, + event: str, + exc: BaseException | None = None, + **kwargs: object, +) -> None: + """Structured error helper: captures full traceback and exception fields.""" + if exc is not None: + kwargs["exc_info"] = (type(exc), exc, exc.__traceback__) + logger.error(event, **kwargs) diff --git a/src/contract_check/core/metrics.py b/src/contract_check/core/metrics.py new file mode 100644 index 0000000..c0c75ae --- /dev/null +++ b/src/contract_check/core/metrics.py @@ -0,0 +1,86 @@ +"""Prometheus metrics registry + a background metrics HTTP server. + +The api exposes `/metrics` itself (FastAPI route). Workers (which don't serve +HTTP for their main job) call `start_metrics_server(port)` to run a tiny +prometheus_client HTTP listener in a background thread on :9101/:9102. + +This module imports `prometheus_client` — only obs-enabled services use it. +""" + +from __future__ import annotations + +import threading + +from prometheus_client import Counter, Gauge, Histogram, start_http_server + +from .logging import get_logger + +log = get_logger(__name__) + +_started: dict[int, threading.Thread] = {} + + +# ── counters ───────────────────────────────────────────────────────────────── +documents_uploaded = Counter( + "contract_check_documents_uploaded_total", + "Documents accepted by the api and enqueued.", +) +credits_reserved = Counter( + "contract_check_credits_reserved_total", + "Credits reserved on enqueue (successful).", +) +credits_refunded = Counter( + "contract_check_credits_refunded_total", + "Credits refunded on terminal failure.", + ["policy"], # all | infra_only +) +mq_published = Counter( + "contract_check_mq_published_total", + "Messages published to RabbitMQ.", + ["queue"], # extract | analyze +) +mq_failed = Counter( + "contract_check_mq_failed_total", + "Jobs that hit a failure class.", + ["queue", "failure_class"], +) +llm_tokens = Counter( + "contract_check_llm_tokens_total", + "LLM tokens consumed by the analyze worker.", + ["kind"], # prompt | eval +) +llm_fell_back = Counter( + "contract_check_llm_fell_back_total", + "Times the LLM provider switched to the fallback model.", +) +redis_connected = Gauge( + "contract_check_redis_connected", + "Whether the api successfully connected to Redis at startup.", +) + + +# ── histograms ─────────────────────────────────────────────────────────────── +http_request_duration = Histogram( + "contract_check_http_request_duration_seconds", + "HTTP request latency.", + ["method", "path", "status"], +) +extract_duration = Histogram( + "contract_check_extract_duration_seconds", + "Document extraction (extract.q handler) latency.", +) +analyze_duration = Histogram( + "contract_check_analyze_duration_seconds", + "Document analysis (analyze.q handler) latency.", +) + + +def start_metrics_server(port: int) -> None: + """Start a prometheus_client HTTP listener on `port` (idempotent per port). + + Used by workers; the api serves /metrics itself instead. + """ + if port in _started: + return + start_http_server(port) + log.info("metrics_server_started", port=port) diff --git a/src/contract_check/core/mq/__init__.py b/src/contract_check/core/mq/__init__.py new file mode 100644 index 0000000..7d9d24e --- /dev/null +++ b/src/contract_check/core/mq/__init__.py @@ -0,0 +1,6 @@ +"""RabbitMQ layer — topology, message schemas, publisher, consumer base. + +See docs/ARCHITECTURE.md §5 for the full topology spec (direct exchange, pipeline +fan-out extract.q → analyze.q, quorum main queues, classic TTL retry queues, +quorum DLQs). All declarations are idempotent and run on every service start. +""" diff --git a/src/contract_check/core/mq/consumer.py b/src/contract_check/core/mq/consumer.py new file mode 100644 index 0000000..86a9b3c --- /dev/null +++ b/src/contract_check/core/mq/consumer.py @@ -0,0 +1,282 @@ +"""RabbitMQ consumer base with retry/DLQ mechanics. + +Subclasses (worker-extract, worker-analyze, Steps 3–4) implement: + - `handle(payload)` — the per-message work; raise on failure + - `classify(exc)` — map an exception to a FailureClass (default unknown) + - `on_failure(...)` — record attempts/last_failure_class (default no-op) + - `on_dlq(...)` — terminal: status=failed + refund (default no-op) + +Retry is MANUAL (plugin-free, exponential): on a classified failure the base +publishes a copy of the message to the retry exchange with `expiration = +base * 2^attempt` and `x-attempt` incremented, then acks the original. When +the retry queue's TTL expires it dead-letters back to the main queue. After +`max_attempts`, the message is published to the matching DLQ instead. + +A body that fails pydantic validation is sent straight to the DLQ with +failure_class="infra" (poison message). See docs/ARCHITECTURE.md §5. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from aio_pika import DeliveryMode, Message, connect_robust +from pydantic import ValidationError + +from ..db.enums import FailureClass +from ..errors import TerminalError +from ..logging import get_logger, set_correlation_id +from .messages import PipelineMessage +from .topology import ( + DLQ_FOR, + EXCHANGE_RETRY, + H_ATTEMPT, + H_CORRELATION_ID, + H_ORIGIN, + declare_all, + ensure_retry_queues_lazy, +) + +if TYPE_CHECKING: + from aio_pika.abc import ( + AbstractChannel, + AbstractConnection, + AbstractIncomingMessage, + AbstractRobustConnection, + ) + +log = get_logger(__name__) + + +def _is_terminal(exc: BaseException) -> bool: + """Return True for errors that will not be fixed by RabbitMQ retries.""" + return isinstance(exc, TerminalError) + + +class Consumer[MsgT: PipelineMessage]: + """Base RabbitMQ consumer bound to one main queue.""" + + #: subclass declares which queue + routing key + message model it owns + queue: str = "" + routing_key: str = "" + message_model: type[PipelineMessage] = PipelineMessage + + def __init__( + self, + url: str, + *, + origin: str, + prefetch: int, + max_attempts: int, + retry_base_ms: int, + ) -> None: + self._url = url + self._origin = origin + self._prefetch = prefetch + self._max_attempts = max(1, max_attempts) + self._retry_base_ms = max(1, retry_base_ms) + self._conn: AbstractRobustConnection | None = None + self._channel: AbstractChannel | None = None + self._stop = asyncio.Event() + + # ── subclass hooks ─────────────────────────────────────────────────────── + async def handle(self, payload: MsgT) -> None: + raise NotImplementedError + + def classify(self, exc: BaseException) -> FailureClass: + return "unknown" + + async def on_failure( + self, payload: MsgT, failure_class: FailureClass, attempt: int, error: str + ) -> None: + """Per-failure hook (update jobs.attempts / last_failure_class).""" + + async def on_dlq(self, payload: MsgT, failure_class: FailureClass, error: str) -> None: + """Terminal hook (set status=failed + refund).""" + + # ── lifecycle ──────────────────────────────────────────────────────────── + async def connect(self) -> None: + log.info("consumer_connecting", queue=self.queue, origin=self._origin) + # Ensure retry queues are lazy on existing deployments before declaring. + # Fresh deployments will get x-queue-mode:lazy from declare_all below. + try: + ensure_retry_queues_lazy(self._url) + except Exception as exc: # noqa: BLE001 + log.warning( + "retry_lazy_policy_failed", + queue=self.queue, + origin=self._origin, + error=f"{type(exc).__name__}: {exc}", + ) + + self._conn = await connect_robust(self._url) + + def _on_connection_close( + conn: AbstractConnection | None, exc: BaseException | None + ) -> None: + if exc is not None: + log.warning( + "rabbitmq_connection_lost", + queue=self.queue, + origin=self._origin, + error=f"{type(exc).__name__}: {exc}", + ) + else: + log.info( + "rabbitmq_connection_closed", + queue=self.queue, + origin=self._origin, + ) + + self._conn.close_callbacks.add(_on_connection_close) + + channel = await self._conn.channel() + self._channel = channel + await channel.set_qos(prefetch_count=self._prefetch) + await declare_all(channel) + log.info( + "consumer_connected", + queue=self.queue, + origin=self._origin, + prefetch=self._prefetch, + ) + + async def run(self) -> None: + if self._channel is None: + await self.connect() + assert self._channel is not None + queue = await self._channel.get_queue(self.queue, ensure=False) + await queue.consume(self._on_message) + await self._stop.wait() + + async def stop(self) -> None: + self._stop.set() + if self._conn is not None: + try: + await self._conn.close() + except Exception as exc: # noqa: BLE001 + log.warning( + "consumer_close_error", + queue=self.queue, + origin=self._origin, + error=f"{type(exc).__name__}: {exc}", + ) + + # ── internals ──────────────────────────────────────────────────────────── + async def _on_message(self, message: AbstractIncomingMessage) -> None: + headers: dict[str, Any] = message.headers or {} + cid = str(headers.get(H_CORRELATION_ID, message.correlation_id or "")) + set_correlation_id(cid) + attempt = int(str(headers.get(H_ATTEMPT, 0) or "0")) + + payload = self._parse(message.body) + if payload is None: + await self._to_dlq(message, "infra", "invalid message body", attempt=0) + return + + try: + await self.handle(payload) + await message.ack() + return + except Exception as exc: # noqa: BLE001 — classified below + failure_class = self.classify(exc) + error = f"{type(exc).__name__}: {exc}" + new_attempt = attempt + 1 + await self.on_failure(payload, failure_class, new_attempt, error) + + if _is_terminal(exc): + log.warning( + "message_terminal_dlq", + queue=self.queue, + correlation_id=cid, + failure_class=failure_class, + ) + await self.on_dlq(payload, failure_class, error) + await self._to_dlq(message, failure_class, error, attempt=new_attempt) + return + + if new_attempt >= self._max_attempts: + log.warning( + "message_max_attempts_dlq", + queue=self.queue, + correlation_id=cid, + attempt=new_attempt, + failure_class=failure_class, + ) + await self.on_dlq(payload, failure_class, error) + await self._to_dlq(message, failure_class, error, attempt=new_attempt) + else: + await self._to_retry(message, payload, new_attempt) + await message.ack() + + def _parse(self, body: bytes) -> MsgT | None: + try: + return self.message_model.model_validate_json(body) # type: ignore[return-value] + except ValidationError as exc: + log.error("message_parse_failed", error=str(exc), body=body[:500]) + return None + + async def _to_retry( + self, message: AbstractIncomingMessage, payload: MsgT, new_attempt: int + ) -> None: + assert self._channel is not None + retry_rk = f"retry.{self.routing_key}" + expiration_ms = int(self._retry_base_ms * (2 ** (new_attempt - 1))) + body = payload.next_attempt().model_dump_json().encode("utf-8") + headers: dict[str, Any] = { + H_CORRELATION_ID: str(payload.correlation_id), + H_ATTEMPT: new_attempt, + H_ORIGIN: self._origin, + } + retry_exchange = await self._channel.get_exchange(EXCHANGE_RETRY, ensure=False) + await retry_exchange.publish( + Message( + body, + content_type="application/json", + delivery_mode=DeliveryMode.PERSISTENT, + correlation_id=str(payload.correlation_id), + headers=headers, + expiration=expiration_ms, + ), + routing_key=retry_rk, + ) + log.info( + "message_retrying", + queue=self.queue, + correlation_id=str(payload.correlation_id), + attempt=new_attempt, + delay_ms=expiration_ms, + ) + + async def _to_dlq( + self, + message: AbstractIncomingMessage, + failure_class: FailureClass, + error: str, + *, + attempt: int, + ) -> None: + assert self._channel is not None + dlq = DLQ_FOR.get(self.queue, f"{self.queue}.dlq") + headers = dict(message.headers or {}) + headers.update( + { + H_CORRELATION_ID: str(message.correlation_id or headers.get(H_CORRELATION_ID, "")), + H_ATTEMPT: attempt, + "x-failure-class": failure_class, + "x-failure-error": error[:1000], + } + ) + await self._channel.default_exchange.publish( + Message( + message.body, + content_type=message.content_type or "application/json", + delivery_mode=DeliveryMode.PERSISTENT, + correlation_id=message.correlation_id, + headers=headers, + ), + routing_key=dlq, + ) + await message.ack() + log.warning("message_to_dlq", dlq=dlq, failure_class=failure_class) diff --git a/src/contract_check/core/mq/messages.py b/src/contract_check/core/mq/messages.py new file mode 100644 index 0000000..dd00c86 --- /dev/null +++ b/src/contract_check/core/mq/messages.py @@ -0,0 +1,49 @@ +"""Message payloads for the RabbitMQ pipeline (pydantic v2). + +Validated on consume; a body that fails validation goes straight to the DLQ +with failure_class=infra. Every message carries `correlation_id` (also placed +in RabbitMQ header `x-correlation-id` by the publisher/consumer) so logs and +traces trace api → rabbit → workers under one id. +""" + +from __future__ import annotations + +import uuid +from typing import Self + +from pydantic import BaseModel, Field + + +class PipelineMessage(BaseModel): + """Base: every pipeline message has these.""" + + correlation_id: uuid.UUID + document_id: uuid.UUID + user_id: uuid.UUID + attempt: int = Field(default=0, ge=0) + + def next_attempt(self) -> Self: + return self.model_copy(update={"attempt": self.attempt + 1}) + + +class DocumentUploaded(PipelineMessage): + """api → contracts.x[extract] → worker-extract. + + `s3_key` points at the raw uploaded blob (`users/{uid}/docs/{did}.{ext}`). + """ + + s3_key: str + filename: str + mime: str + + +class DocumentExtracted(PipelineMessage): + """worker-extract → contracts.x[analyze] → worker-analyze. + + `extracted_s3_key` points at the extracted plaintext + (`users/{uid}/docs/{did}.txt`). + """ + + extracted_s3_key: str + char_count: int + ocr_used: bool diff --git a/src/contract_check/core/mq/publisher.py b/src/contract_check/core/mq/publisher.py new file mode 100644 index 0000000..34e9853 --- /dev/null +++ b/src/contract_check/core/mq/publisher.py @@ -0,0 +1,81 @@ +"""RabbitMQ publisher with publisher confirms. + +The api publishes `DocumentUploaded` on upload; worker-extract publishes +`DocumentExtracted` on success. Publisher confirms are ON: a publish that is +not confirmed raises, so a paid job's message can never silently vanish (the +api fails the HTTP request instead of returning a false 202). + +Every published message carries the correlation_id in both `message_id` and +the `x-correlation-id` header for log/trace propagation. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from aio_pika import DeliveryMode, Message, connect_robust + +from ..logging import get_logger +from .messages import PipelineMessage +from .topology import EXCHANGE_MAIN, H_ATTEMPT, H_CORRELATION_ID, H_ORIGIN, declare_all + +if TYPE_CHECKING: + from aio_pika.abc import AbstractChannel, AbstractExchange, AbstractRobustConnection + +log = get_logger(__name__) + + +class Publisher: + """Robust RabbitMQ publisher bound to the main exchange.""" + + def __init__(self, url: str, *, origin: str = "api") -> None: + self._url = url + self._origin = origin + self._conn: AbstractRobustConnection | None = None + self._channel: AbstractChannel | None = None + self._exchange: AbstractExchange | None = None + + async def connect(self) -> None: + self._conn = await connect_robust(self._url) + channel = await self._conn.channel(publisher_confirms=True) + self._channel = channel + await declare_all(channel) + self._exchange = await channel.declare_exchange(EXCHANGE_MAIN, durable=True) + log.info("publisher_connected", url=self._url, origin=self._origin) + + async def publish(self, message: PipelineMessage, routing_key: str) -> None: + """Publish a PipelineMessage to the main exchange. Raises on no-confirm.""" + if self._exchange is None: + raise RuntimeError("Publisher not connected; call connect() first") + body = message.model_dump_json().encode("utf-8") + amqp = Message( + body, + content_type="application/json", + delivery_mode=DeliveryMode.PERSISTENT, + correlation_id=str(message.correlation_id), + message_id=str(message.correlation_id), + headers={ + H_CORRELATION_ID: str(message.correlation_id), + H_ATTEMPT: message.attempt, + H_ORIGIN: self._origin, + }, + ) + await self._exchange.publish(amqp, routing_key=routing_key) + log.debug( + "published", + routing_key=routing_key, + correlation_id=str(message.correlation_id), + attempt=message.attempt, + ) + + async def close(self) -> None: + if self._conn is not None: + await self._conn.close() + self._conn = self._channel = self._exchange = None + + async def __aenter__(self) -> Publisher: + await self.connect() + return self + + async def __aexit__(self, *exc: object) -> None: + await self.close() diff --git a/src/contract_check/core/mq/topology.py b/src/contract_check/core/mq/topology.py new file mode 100644 index 0000000..dc67f42 --- /dev/null +++ b/src/contract_check/core/mq/topology.py @@ -0,0 +1,206 @@ +"""RabbitMQ topology — exchange/queue/routing-key names + idempotent declare. + +Layout (docs/ARCHITECTURE.md §5): + + contracts.x (direct) + ├─ extract ─► extract.q (quorum; consumed by worker-extract) + └─ analyze ─► analyze.q (quorum; consumed by worker-analyze) + + contracts.retry.x (direct) ← DLX target of main queues on nack + ├─ retry.extract ─► extract.retry.q (classic; per-msg TTL; DLX→contracts.x[extract]) + └─ retry.analyze ─► analyze.retry.q (classic; per-msg TTL; DLX→contracts.x[analyze]) + + extract.dlq / analyze.dlq (quorum; manual requeue after max attempts) + +Retry is plugin-free: the main queue's DLX routes nacked messages to the retry +exchange; the retry queue's per-message TTL, on expiry, dead-letters back to +the main exchange → the message re-enters the main quorum queue. + +The retry queues are additionally forced to `queue-mode: lazy` via a RabbitMQ +policy. Without lazy mode, classic queues keep per-message TTL messages in RAM +and do not expire them while idle, so retries can sit forever. The policy is +idempotent and applies to existing queues without redeclaring them. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +if TYPE_CHECKING: + from aio_pika.abc import AbstractChannel + +# ── exchanges ──────────────────────────────────────────────────────────────── +EXCHANGE_MAIN = "contracts.x" +EXCHANGE_RETRY = "contracts.retry.x" + +# ── queues ─────────────────────────────────────────────────────────────────── +QUEUE_EXTRACT = "extract.q" +QUEUE_ANALYZE = "analyze.q" +QUEUE_EXTRACT_RETRY = "extract.retry.q" +QUEUE_ANALYZE_RETRY = "analyze.retry.q" +QUEUE_EXTRACT_DLQ = "extract.dlq" +QUEUE_ANALYZE_DLQ = "analyze.dlq" + +# ── routing keys ───────────────────────────────────────────────────────────── +RK_EXTRACT = "extract" +RK_ANALYZE = "analyze" +RK_RETRY_EXTRACT = "retry.extract" +RK_RETRY_ANALYZE = "retry.analyze" + +# ── message headers ────────────────────────────────────────────────────────── +H_CORRELATION_ID = "x-correlation-id" +H_ATTEMPT = "x-attempt" +H_ORIGIN = "x-origin" + +# Queue type args. Quorum queues are HA-ready (persisted; replicate when the +# RabbitMQ cluster grows to 3 nodes — no app code change). Retry queues are +# classic transient delay slots (safe to lose; the originating job is acked). +_QUORUM_ARGS: dict[str, Any] = {"x-queue-type": "quorum"} +_RETRY_QUEUE_FOR: dict[str, tuple[str, str, str]] = { + # main_queue: (retry_exchange, retry_routing_key, main_routing_key_back) + QUEUE_EXTRACT: (EXCHANGE_RETRY, RK_RETRY_EXTRACT, RK_EXTRACT), + QUEUE_ANALYZE: (EXCHANGE_RETRY, RK_RETRY_ANALYZE, RK_ANALYZE), +} + +# Reverse lookup: queue name → its DLQ name. +DLQ_FOR: dict[str, str] = { + QUEUE_EXTRACT: QUEUE_EXTRACT_DLQ, + QUEUE_ANALYZE: QUEUE_ANALYZE_DLQ, +} + + +async def declare_all(channel: AbstractChannel) -> None: + """Idempotently declare every exchange, queue, and binding.""" + main_exchange = await channel.declare_exchange(EXCHANGE_MAIN, durable=True) + retry_exchange = await channel.declare_exchange(EXCHANGE_RETRY, durable=True) + + # Main quorum queues: DLX → retry exchange. + for queue, retry_rk in ( + (QUEUE_EXTRACT, RK_RETRY_EXTRACT), + (QUEUE_ANALYZE, RK_RETRY_ANALYZE), + ): + q = await channel.declare_queue( + queue, + durable=True, + arguments={ + **_QUORUM_ARGS, + "x-dead-letter-exchange": EXCHANGE_RETRY, + "x-dead-letter-routing-key": retry_rk, + }, + ) + await q.bind(main_exchange, routing_key=retry_rk.replace("retry.", "")) + + # Classic retry queues: DLX → main exchange (back to the main routing key). + # The retry queues are kept lazy via the policy set by + # ensure_retry_queues_lazy() so that per-message TTL expires even when no + # consumer is attached. Including x-queue-mode here would break upgrades + # because RabbitMQ treats it as a queue argument that cannot be changed on + # an existing queue. + for retry_queue, main_rk in ( + (QUEUE_EXTRACT_RETRY, RK_EXTRACT), + (QUEUE_ANALYZE_RETRY, RK_ANALYZE), + ): + rq = await channel.declare_queue( + retry_queue, + durable=True, + arguments={ + "x-dead-letter-exchange": EXCHANGE_MAIN, + "x-dead-letter-routing-key": main_rk, + }, + ) + await rq.bind(retry_exchange, routing_key=f"retry.{main_rk}") + + # Quorum DLQs (no bindings; published to directly via default exchange). + for dlq in (QUEUE_EXTRACT_DLQ, QUEUE_ANALYZE_DLQ): + await channel.declare_queue(dlq, durable=True, arguments=_QUORUM_ARGS) + + +# ── lazy-mode policy for existing (non-lazy) retry queues ─────────────────── +# RabbitMQ classic queues only expire per-message TTL when the message reaches +# the head of the queue. In default mode the queue may keep messages in RAM +# indefinitely while idle; lazy mode forces paging to disk and enables expiry. +# This policy is applied in addition to the x-queue-mode declaration above so +# that already-created retry queues are corrected without being deleted. +_RETRY_QUEUE_POLICY_NAME = "contract_check_retry_lazy" +_RETRY_QUEUE_POLICY_PATTERN = "^.+\\.retry\\.q$" +_RETRY_QUEUE_POLICY_DEF = {"queue-mode": "lazy"} + + +def _management_url_from_amqp(amqp_url: str) -> str | None: + """Derive a likely RabbitMQ management URL from the AMQP URL. + + Returns None when the URL cannot be parsed into something usable. + """ + try: + parsed = urlparse(amqp_url) + except ValueError: + return None + if not parsed.hostname: + return None + scheme = "https" if parsed.scheme.endswith("s") else "http" + port = parsed.port or (5671 if scheme == "https" else 5672) + # Common management port mapping: 5672 -> 15672, 5671 -> 15671. + mgmt_port = 15671 if port == 5671 else 15672 + return f"{scheme}://{parsed.hostname}:{mgmt_port}" + + +def _amqp_credentials(amqp_url: str) -> tuple[str, str] | None: + """Return (username, password) from the AMQP URL, or None if absent.""" + try: + parsed = urlparse(amqp_url) + except ValueError: + return None + if not parsed.username or parsed.password is None: + return None + return parsed.username, parsed.password + + +def ensure_retry_queues_lazy(amqp_url: str) -> None: + """Set the lazy-mode policy for *.retry.q queues via the management API. + + This is best-effort: if the management plugin is not reachable the + declaration-time x-queue-mode still protects fresh deployments. + """ + mgmt_url = _management_url_from_amqp(amqp_url) + creds = _amqp_credentials(amqp_url) + if not mgmt_url or not creds: + return + + import base64 + + username, password = creds + auth = base64.b64encode(f"{username}:{password}".encode()).decode() + payload = json.dumps( + { + "pattern": _RETRY_QUEUE_POLICY_PATTERN, + "apply-to": "queues", + "definition": _RETRY_QUEUE_POLICY_DEF, + "priority": 0, + } + ).encode("utf-8") + url = f"{mgmt_url}/api/policies/%2F/{_RETRY_QUEUE_POLICY_NAME}" + req = urllib.request.Request( + url, + data=payload, + method="PUT", + headers={ + "Authorization": f"Basic {auth}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status in (201, 204): + return + except urllib.error.HTTPError as exc: + # 204 No Content is the success response for PUT on existing policy. + if exc.code == 204: + return + except Exception: + # Management plugin may be disabled; declaration-time x-queue-mode is + # the fallback for fresh deployments. + pass diff --git a/src/contract_check/core/rate_limit.py b/src/contract_check/core/rate_limit.py new file mode 100644 index 0000000..49a4bf0 --- /dev/null +++ b/src/contract_check/core/rate_limit.py @@ -0,0 +1,131 @@ +"""Token-bucket rate limiter with Redis backend. + +Default B2B limit mirrors Ollama Pro concurrency (3 req/s) so burst traffic +from API clients cannot outrun the LLM provider. Per-key override lives in +`api_keys.rate_limit_rps`. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any, Protocol + +from .logging import get_logger + +log = get_logger(__name__) + + +class RateLimiterUnavailable(Exception): + """Raised when the rate-limiter backend cannot be reached.""" + + +class RateLimiter(Protocol): + """Backend-agnostic rate limiter interface.""" + + async def allow(self, key: str, limit_rps: int) -> RateLimitResult: ... + + +@dataclass(frozen=True) +class RateLimitResult: + allowed: bool + retry_after_sec: float | None + + +class RedisRateLimiter: + """Token-bucket rate limiter backed by Redis (atomic Lua script).""" + + # Capacity = limit_rps tokens, refill rate = limit_rps tokens/second. + # This allows a burst of `limit_rps` requests and then throttles to the + # same sustained rate. + _SCRIPT = """ + local key = KEYS[1] + local capacity = tonumber(ARGV[1]) + local refill_rate = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local cost = 1 + + local bucket = redis.call("HMGET", key, "tokens", "last_refill") + local tokens = tonumber(bucket[1]) + local last_refill = tonumber(bucket[2]) + + if tokens == nil then + tokens = capacity + last_refill = now + end + + local elapsed = now - last_refill + tokens = math.min(capacity, tokens + elapsed * refill_rate) + + if tokens >= cost then + tokens = tokens - cost + redis.call("HMSET", key, "tokens", tokens, "last_refill", now) + redis.call("EXPIRE", key, 3600) + return {1, 0} + else + local deficit = cost - tokens + local retry_after = deficit / refill_rate + redis.call("HMSET", key, "tokens", tokens, "last_refill", now) + redis.call("EXPIRE", key, 3600) + return {0, retry_after} + end + """ + + def __init__(self, redis: Any) -> None: + self._redis = redis + self._script_sha: str | None = None + + async def _ensure_script(self) -> str: + if self._script_sha is None: + self._script_sha = await self._redis.script_load(self._SCRIPT) + return self._script_sha + + async def allow(self, key: str, limit_rps: int) -> RateLimitResult: + if limit_rps <= 0: + # Defensive: a misconfigured key should not block all traffic. + log.warning("rate_limit_misconfigured", key=key, limit_rps=limit_rps) + return RateLimitResult(allowed=True, retry_after_sec=None) + + capacity = float(limit_rps) + refill_rate = float(limit_rps) + now = time.monotonic() + + try: + sha = await self._ensure_script() + raw = await self._redis.evalsha(sha, 1, key, str(capacity), str(refill_rate), str(now)) + except Exception as exc: # pragma: no cover - Redis unavailable + log.error("rate_limit_redis_error", key=key, error=str(exc)) + raise RateLimiterUnavailable("rate limiter backend unavailable") from exc + + # Redis returns a list of integers/floats as strings; unpack. + allowed = int(raw[0]) == 1 + retry_after = None if allowed else float(raw[1]) + return RateLimitResult(allowed=allowed, retry_after_sec=retry_after) + + +class MemoryRateLimiter: + """In-memory token bucket for unit tests and dev without Redis.""" + + def __init__(self) -> None: + self._buckets: dict[str, tuple[float, float]] = {} + + async def allow(self, key: str, limit_rps: int) -> RateLimitResult: + if limit_rps <= 0: + # Match RedisRateLimiter: misconfigured limits fail open. + log.warning("rate_limit_misconfigured", key=key, limit_rps=limit_rps) + return RateLimitResult(allowed=True, retry_after_sec=None) + + capacity = float(limit_rps) + refill_rate = float(limit_rps) + now = time.monotonic() + + tokens, last_refill = self._buckets.get(key, (capacity, now)) + tokens = min(capacity, tokens + (now - last_refill) * refill_rate) + + if tokens >= 1.0: + self._buckets[key] = (tokens - 1.0, now) + return RateLimitResult(allowed=True, retry_after_sec=None) + + retry_after = (1.0 - tokens) / refill_rate + self._buckets[key] = (tokens, now) + return RateLimitResult(allowed=False, retry_after_sec=retry_after) diff --git a/src/contract_check/core/redis_client.py b/src/contract_check/core/redis_client.py new file mode 100644 index 0000000..c5376b2 --- /dev/null +++ b/src/contract_check/core/redis_client.py @@ -0,0 +1,19 @@ +"""Async Redis client factory. + +Used for rate-limiting and future sessions. NOT a job queue — RabbitMQ owns that. +""" + +from __future__ import annotations + +from typing import Any + + +def get_redis_client(redis_url: str) -> Any: + """Create a new async Redis client from a Redis DSN. + + Callers own the connection lifecycle. The api creates one client at startup + and stores it in app.state.redis. + """ + from redis.asyncio import Redis as AsyncRedis + + return AsyncRedis.from_url(redis_url, decode_responses=True) diff --git a/src/contract_check/core/s3/__init__.py b/src/contract_check/core/s3/__init__.py new file mode 100644 index 0000000..7b39ccb --- /dev/null +++ b/src/contract_check/core/s3/__init__.py @@ -0,0 +1,39 @@ +"""Object storage layer — Storage port + MinIO adapter + key builders. + +See docs/ARCHITECTURE.md §6. Adapters never import this; only the api and workers +do. Layout: users/{uid}/docs/{did}.{ext} (raw) and {did}.txt (extracted). +The MinIO SDK is sync; the adapter offloads every call to a thread so the +async event loop is never blocked. +""" + +from __future__ import annotations + +import urllib.parse + +from .port import ObjectInfo, Storage + +__all__ = ["Storage", "ObjectInfo", "original_key", "extracted_key", "key_user_prefix"] + + +def original_key(user_id: str, document_id: str, ext: str) -> str: + """Raw upload object key: `users/{uid}/docs/{did}.{ext}`.""" + ext = ext.lstrip(".") + return f"users/{user_id}/docs/{document_id}.{ext}" + + +def extracted_key(user_id: str, document_id: str) -> str: + """Extracted-plaintext object key: `users/{uid}/docs/{did}.txt`.""" + return f"users/{user_id}/docs/{document_id}.txt" + + +def key_user_prefix(user_id: str) -> str: + """Prefix to enumerate/purge all of a user's objects.""" + return f"users/{user_id}/docs/" + + +def endpoint_host_port(endpoint_url: str) -> tuple[str, bool]: + """Split an S3_ENDPOINT_URL into (host:port, secure) for the MinIO client.""" + parsed = urllib.parse.urlparse(endpoint_url) + secure = parsed.scheme == "https" + netloc = parsed.netloc or parsed.path + return netloc, secure diff --git a/src/contract_check/core/s3/minio_storage.py b/src/contract_check/core/s3/minio_storage.py new file mode 100644 index 0000000..2c1f1b0 --- /dev/null +++ b/src/contract_check/core/s3/minio_storage.py @@ -0,0 +1,103 @@ +"""MinIO adapter implementing the Storage port. + +Sync `minio` SDK calls are wrapped in `asyncio.to_thread` so the async loop +in the api/workers never blocks on network I/O. `ensure_bucket()` is the lazy +safety net behind the `minio-init` compose service. +""" + +from __future__ import annotations + +import asyncio +import io +from typing import Any + +from minio import Minio + +from ..logging import get_logger +from .port import ObjectInfo + +log = get_logger(__name__) + + +class MinioStorage: + """Async-friendly MinIO storage adapter.""" + + def __init__( + self, + endpoint: str, + access_key: str, + secret_key: str, + bucket: str, + *, + secure: bool = False, + region: str = "us-east-1", + ) -> None: + self._bucket = bucket + self._client: Any = Minio( + endpoint, + access_key=access_key, + secret_key=secret_key, + secure=secure, + region=region, + ) + + @classmethod + def from_endpoint_url( + cls, + endpoint_url: str, + access_key: str, + secret_key: str, + bucket: str, + *, + region: str = "us-east-1", + ) -> MinioStorage: + from . import endpoint_host_port + + host, secure = endpoint_host_port(endpoint_url) + return cls(host, access_key, secret_key, bucket, secure=secure, region=region) + + async def ensure_bucket(self) -> None: + await asyncio.to_thread(self._ensure_bucket_sync) + + def _ensure_bucket_sync(self) -> None: + if not self._client.bucket_exists(self._bucket): + self._client.make_bucket(self._bucket) + log.info("bucket_created", bucket=self._bucket) + + async def put( + self, key: str, data: bytes, *, content_type: str = "application/octet-stream" + ) -> ObjectInfo: + return await asyncio.to_thread(self._put_sync, key, data, content_type) + + def _put_sync(self, key: str, data: bytes, content_type: str) -> ObjectInfo: + stream = io.BytesIO(data) + result = self._client.put_object( + self._bucket, key, stream, length=len(data), content_type=content_type + ) + return ObjectInfo(key=key, size=len(data), etag=result.etag if result else None) + + async def get(self, key: str) -> bytes: + data: bytes = await asyncio.to_thread(self._get_sync, key) + return data + + def _get_sync(self, key: str) -> bytes: + response = self._client.get_object(self._bucket, key) + try: + data: bytes = response.read() + return data + finally: + response.close() + response.release_conn() + + async def delete(self, key: str) -> None: + await asyncio.to_thread(self._client.remove_object, self._bucket, key) + + async def stat(self, key: str) -> ObjectInfo | None: + return await asyncio.to_thread(self._stat_sync, key) + + def _stat_sync(self, key: str) -> ObjectInfo | None: + try: + stat = self._client.stat_object(self._bucket, key) + except Exception: + return None + return ObjectInfo(key=key, size=stat.size, etag=stat.etag) diff --git a/src/contract_check/core/s3/port.py b/src/contract_check/core/s3/port.py new file mode 100644 index 0000000..3cedf22 --- /dev/null +++ b/src/contract_check/core/s3/port.py @@ -0,0 +1,31 @@ +"""Storage port (hexagonal). The MinIO adapter and any future adapter +(Selectel, in-memory for tests) implement this Protocol.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + + +@dataclass(slots=True) +class ObjectInfo: + key: str + size: int + etag: str | None + + +@runtime_checkable +class Storage(Protocol): + """Object storage port used by the api and both workers.""" + + async def ensure_bucket(self) -> None: ... + + async def put( + self, key: str, data: bytes, *, content_type: str = "application/octet-stream" + ) -> ObjectInfo: ... + + async def get(self, key: str) -> bytes: ... + + async def delete(self, key: str) -> None: ... + + async def stat(self, key: str) -> ObjectInfo | None: ... diff --git a/src/contract_check/core/sentry.py b/src/contract_check/core/sentry.py new file mode 100644 index 0000000..4432f44 --- /dev/null +++ b/src/contract_check/core/sentry.py @@ -0,0 +1,31 @@ +"""Sentry initialization helper. + +`init_sentry()` is called by each service entrypoint. No-op when `SENTRY_DSN` +is empty. Imports `sentry_sdk` — only obs-enabled services use it. +""" + +from __future__ import annotations + +from .config import get_settings +from .logging import get_logger + +log = get_logger(__name__) + + +def init_sentry(service_name: str | None = None) -> None: + """Initialize Sentry SDK. Safe to call when no DSN is configured.""" + settings = get_settings() + dsn = settings.sentry_dsn + if not dsn: + return + + import sentry_sdk + + traces_sample_rate = 1.0 if settings.env == "dev" else 0.1 + sentry_sdk.init( + dsn=dsn, + environment=settings.env, + traces_sample_rate=traces_sample_rate, + send_default_pii=False, + ) + log.info("sentry_initialized", service=service_name or settings.otel_service_name) diff --git a/src/contract_check/core/telemetry.py b/src/contract_check/core/telemetry.py new file mode 100644 index 0000000..dce0b67 --- /dev/null +++ b/src/contract_check/core/telemetry.py @@ -0,0 +1,73 @@ +"""OpenTelemetry initialization. + +Sets up a tracer exporting OTLP to the collector when +`OTEL_EXPORTER_OTLP_ENDPOINT` is set; otherwise no-op. httpx auto-instrumentation +is wired so Ollama Cloud calls appear as spans. Service entrypoints call +`setup_telemetry()` early and `shutdown_telemetry()` on exit. + +This module imports `opentelemetry` — only services in the `obs` group import it +(see docs/ARCHITECTURE.md §4). mypy resolves it via the dev group. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .config import get_settings +from .logging import get_logger + +if TYPE_CHECKING: + from opentelemetry.sdk.trace import TracerProvider + +log = get_logger(__name__) + +_provider: TracerProvider | None = None + + +def setup_telemetry(service_name: str | None = None) -> None: + """Initialize OTel tracing. Safe to call multiple times / when disabled.""" + global _provider + settings = get_settings() + endpoint = settings.otel_exporter_otlp_endpoint + if not endpoint: + log.debug("otel_disabled", reason="no endpoint configured") + return + + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.trace import set_tracer_provider + + resource = Resource.create({"service.name": service_name or settings.otel_service_name}) + provider = TracerProvider(resource=resource) + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) + set_tracer_provider(provider) + _provider = provider + + try: + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + + HTTPXClientInstrumentor().instrument() + except Exception as exc: # pragma: no cover - optional instrumentor + log.warning("otel_httpx_instrument_failed", error=str(exc)) + + log.info("otel_initialized", endpoint=endpoint) + + +def shutdown_telemetry() -> None: + """Flush and shut down the tracer provider if it was initialized.""" + global _provider + if _provider is None: + return + try: + _provider.shutdown() + finally: + _provider = None + + +def get_tracer(name: str | None = None) -> Any: + """Return a tracer. Returns a no-op-safe tracer when OTel is absent.""" + from opentelemetry import trace + + return trace.get_tracer(name or "contract_check") diff --git a/src/contract_check/core/tokens.py b/src/contract_check/core/tokens.py new file mode 100644 index 0000000..53f20cb --- /dev/null +++ b/src/contract_check/core/tokens.py @@ -0,0 +1,41 @@ +"""Service-token hashing/verification (no FastAPI here — pure crypto helpers). + +The api turns `Authorization: Bearer ` into a lookup: hash the bearer, +match against `service_tokens.token_hash` where `revoked = FALSE`. Per-adapter +tokens are revocable and auditable (docs/ARCHITECTURE.md §18/Q18). The FastAPI +dependency that uses these lives in `api/deps.py` (Step 2). +""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets + +from .db.enums import ADAPTER_NAMES, AdapterName + + +def generate_token() -> str: + """Mint a new opaque bearer secret (URL-safe, ~43 chars).""" + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + """SHA-256 hex digest of a bearer token (store this, never the raw token).""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def verify_token(token: str, token_hash: str) -> bool: + """Constant-time check that `token` matches the stored `token_hash`.""" + digest = hash_token(token) + return hmac.compare_digest(digest, token_hash) + + +def is_valid_adapter(adapter: str) -> bool: + return adapter in ADAPTER_NAMES + + +def assert_adapter(adapter: str) -> AdapterName: + if not is_valid_adapter(adapter): + raise ValueError(f"invalid adapter {adapter!r}; expected one of {ADAPTER_NAMES}") + return adapter # type: ignore[return-value] diff --git a/src/contract_check/prototype/__init__.py b/src/contract_check/prototype/__init__.py new file mode 100644 index 0000000..223a653 --- /dev/null +++ b/src/contract_check/prototype/__init__.py @@ -0,0 +1,158 @@ +"""«Контракт-чек» — Stage 0 prototype (standalone benchmark CLI). + +Preserved on purpose (docs/ARCHITECTURE.md Q44): a run-once end-to-end CLI for +go/no-go measurements against a real contract — PDF/DOCX → text → chunks → +Ollama Cloud → markdown report + metrics. It does NOT use Postgres/RabbitMQ/ +MinIO; it builds an OllamaCloudProvider directly from OLLAMA_* env vars. + + python -m contract_check.prototype path/to/contract.pdf + python -m contract_check.prototype contract.docx --out report.md + python -m contract_check.prototype contract.pdf --json metrics.json + +Не заменяет юриста. Это первичный скрининг рисков. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +from ..core.analysis.analyzer import RenderMetrics, render_markdown +from ..core.analysis.checklist import checklist_for_prompt +from ..core.analysis.extractor import ExtractionError, extract_text +from ..core.llm.ollama_cloud import LLMError, OllamaCloudProvider +from ..core.logging import configure_logging + + +class PrototypeSettings(BaseSettings): + """OLLAMA_* config only — the prototype needs no DB/MQ/S3.""" + + model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False) + + ollama_host: str = "" + ollama_api_key: str = "" + ollama_model: str = "qwen2.5:14b" + ollama_fallback_model: str = "qwen2.5:7b" + ollama_temperature: float = 0.2 + ollama_num_predict: int = 3072 + ollama_timeout: float = 120.0 + ollama_max_concurrency: int = 3 + chunk_size_chars: int = 10000 + + +def _build_provider(s: PrototypeSettings) -> OllamaCloudProvider: + if not s.ollama_host or not s.ollama_api_key: + sys.exit( + "ERROR: OLLAMA_HOST и OLLAMA_API_KEY обязательны. " + "Скопируй .env.example → .env и заполни (см. ollama.com/cloud)." + ) + return OllamaCloudProvider( + host=s.ollama_host, + api_key=s.ollama_api_key, + model=s.ollama_model, + fallback_model=s.ollama_fallback_model or None, + temperature=s.ollama_temperature, + num_predict=s.ollama_num_predict, + timeout=s.ollama_timeout, + max_concurrency=s.ollama_max_concurrency, + chunk_size=s.chunk_size_chars, + ) + + +async def run(path: Path, out: Path | None, json_out: Path | None) -> int: + settings = PrototypeSettings() + started = asyncio.get_event_loop().time() + + try: + text = extract_text(path) + except ExtractionError as exc: + print(f"ERROR извлечения: {exc}", file=sys.stderr) + return 2 + + provider = _build_provider(settings) + try: + async with provider: + try: + result = await provider.analyze(text, checklist=checklist_for_prompt()) + except LLMError as exc: + print(f"ERROR LLM: {exc}", file=sys.stderr) + return 3 + finally: + latency = asyncio.get_event_loop().time() - started + + metrics = RenderMetrics( + chars=len(text), + findings=len(result.findings), + prompt_tokens=result.prompt_tokens, + eval_tokens=result.eval_tokens, + latency_sec=max(result.latency_sec, latency), + models_used=result.models_used, + fell_back=result.fell_back, + repaired=result.repaired, + ) + md = render_markdown(result.findings, str(path), metrics) + + if out: + out.write_text(md, encoding="utf-8") + print(f"Отчёт сохранён: {out}") + else: + print(md) + + if json_out: + json_out.write_text( + json.dumps( + { + "file": path.name, + "findings": [f.model_dump() for f in result.findings], + "metrics": { + "chars": metrics.chars, + "findings": metrics.findings, + "prompt_tokens": metrics.prompt_tokens, + "eval_tokens": metrics.eval_tokens, + "total_tokens": metrics.total_tokens, + "latency_sec": round(metrics.latency_sec, 2), + "models_used": sorted(metrics.models_used), + "fell_back": metrics.fell_back, + "repaired": metrics.repaired, + }, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + print(f"Метрики/JSON сохранены: {json_out}") + + return 0 + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="contract-check", + description="«Контракт-чек» — первичный скрининг рисков договора (Stage 0).", + ) + parser.add_argument("file", help="Путь к PDF/DOCX договору.") + parser.add_argument("--out", "-o", help="Записать markdown-отчёт в файл.") + parser.add_argument("--json", help="Дополнительно записать находки + метрики.") + parser.add_argument("-v", "--verbose", action="store_true", help="Подробное логирование.") + args = parser.parse_args() + + configure_logging( + level="DEBUG" if args.verbose else "WARNING", + json_output=False, + service="contract-check-prototype", + ) + + rc = asyncio.run( + run( + Path(args.file), + Path(args.out) if args.out else None, + Path(args.json) if args.json else None, + ) + ) + sys.exit(rc) diff --git a/src/contract_check/prototype/__main__.py b/src/contract_check/prototype/__main__.py new file mode 100644 index 0000000..876be50 --- /dev/null +++ b/src/contract_check/prototype/__main__.py @@ -0,0 +1,6 @@ +"""Stage-0 prototype entrypoint: `python -m contract_check.prototype `.""" + +from . import main + +if __name__ == "__main__": + main() diff --git a/src/contract_check/worker_analyze/__init__.py b/src/contract_check/worker_analyze/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/contract_check/worker_analyze/__main__.py b/src/contract_check/worker_analyze/__main__.py new file mode 100644 index 0000000..17bc9b5 --- /dev/null +++ b/src/contract_check/worker_analyze/__main__.py @@ -0,0 +1,62 @@ +"""worker-analyze entrypoint: connects to RabbitMQ and runs the analyze consumer.""" + +from __future__ import annotations + +import asyncio +import signal + +from ..core.config import get_settings +from ..core.logging import bind_context, configure_logging, get_logger +from ..core.metrics import start_metrics_server +from ..core.sentry import init_sentry +from ..core.telemetry import setup_telemetry, shutdown_telemetry +from .consumer import AnalyzeConsumer + +log = get_logger(__name__) + + +async def main() -> None: + settings = get_settings() + configure_logging( + settings.log_level, + json_output=settings.json_logs, + service="worker-analyze", + env=settings.env, + ) + bind_context(service="worker-analyze", env=settings.env) + init_sentry("worker-analyze") + setup_telemetry("worker-analyze") + + start_metrics_server(9102) + + consumer = AnalyzeConsumer( + url=settings.rabbitmq_url, + origin="worker-analyze", + prefetch=settings.mq_prefetch_analyze, + max_attempts=settings.mq_max_attempts, + retry_base_ms=settings.mq_retry_base_ms, + ) + await consumer.connect() + + loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop_event.set) + + consumer_task = asyncio.create_task(consumer.run()) + stop_task = asyncio.create_task(stop_event.wait()) + + log.info("worker_analyze_started", prefetch=settings.mq_prefetch_analyze) + try: + await asyncio.wait( + {consumer_task, stop_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + await consumer.stop() + shutdown_telemetry() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/contract_check/worker_analyze/consumer.py b/src/contract_check/worker_analyze/consumer.py new file mode 100644 index 0000000..4b872a4 --- /dev/null +++ b/src/contract_check/worker_analyze/consumer.py @@ -0,0 +1,67 @@ +"""worker-analyze consumer: wires the analyze handler into the RabbitMQ base.""" + +from __future__ import annotations + +from ..core.db.enums import FailureClass +from ..core.db.session import create_session_factory +from ..core.llm.port import LLMProvider +from ..core.logging import get_logger +from ..core.metrics import analyze_duration +from ..core.mq.consumer import Consumer +from ..core.mq.messages import DocumentExtracted +from .handler import AnalyzeHandler + +log = get_logger(__name__) + + +class AnalyzeConsumer(Consumer[DocumentExtracted]): + """Consumes `analyze.q`, runs the LLM analysis, persists the report.""" + + queue: str = "analyze.q" + routing_key: str = "analyze" + message_model = DocumentExtracted + + def __init__( + self, + url: str, + *, + origin: str, + prefetch: int, + max_attempts: int, + retry_base_ms: int, + provider: LLMProvider | None = None, + ) -> None: + super().__init__( + url, + origin=origin, + prefetch=prefetch, + max_attempts=max_attempts, + retry_base_ms=retry_base_ms, + ) + self._session_factory = create_session_factory() + self._handler = AnalyzeHandler(session_factory=self._session_factory, provider=provider) + + def classify(self, exc: BaseException) -> FailureClass: + return self._handler.classify(exc) + + @analyze_duration.time() + async def handle(self, payload: DocumentExtracted) -> None: + await self._handler.handle(payload) + + async def on_failure( + self, + payload: DocumentExtracted, + failure_class: FailureClass, + attempt: int, + error: str, + ) -> None: + await self._handler.on_failure(payload, failure_class, attempt, error) + + async def on_dlq( + self, payload: DocumentExtracted, failure_class: FailureClass, error: str + ) -> None: + await self._handler.on_terminal_failure(payload, failure_class, error) + + async def stop(self) -> None: + await super().stop() + await self._handler.aclose() diff --git a/src/contract_check/worker_analyze/handler.py b/src/contract_check/worker_analyze/handler.py new file mode 100644 index 0000000..cf532b5 --- /dev/null +++ b/src/contract_check/worker_analyze/handler.py @@ -0,0 +1,257 @@ +"""Analyze worker handler: download extracted text -> LLM analyze -> save report. + +The handler is intentionally separate from the consumer so it can be tested +in-process without spinning up a real RabbitMQ consumer. It owns: + - idempotency checks against documents.status + - status transitions (analyzing -> done | failed) + - MinIO download of the extracted plaintext + - LLM analysis via core.llm (provider port; chunking/repair live in the adapter) + - rendering + persisting the report (JSONB + markdown, disclaimer guaranteed) + - updating the jobs row, recording failure class, and refund-on-DLQ. +""" + +from __future__ import annotations + +import json +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import text + +from ..core.analysis.analyzer import RenderMetrics, render_markdown +from ..core.analysis.checklist import checklist_for_prompt +from ..core.config import get_settings +from ..core.credits import refund_credit +from ..core.db.enums import DOC_TERMINAL, FailureClass +from ..core.llm.factory import build_llm_provider +from ..core.llm.port import LLMProvider +from ..core.logging import get_logger +from ..core.metrics import analyze_duration, llm_fell_back, llm_tokens, mq_failed +from ..core.mq.messages import DocumentExtracted +from ..core.s3.minio_storage import MinioStorage + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +log = get_logger(__name__) + + +class AnalyzeHandler: + """Business logic for worker-analyze.""" + + def __init__( + self, + *, + session_factory: async_sessionmaker[AsyncSession], + provider: LLMProvider | None = None, + ) -> None: + self._session_factory = session_factory + self._settings = get_settings() + self._storage = MinioStorage.from_endpoint_url( + endpoint_url=self._settings.s3_endpoint_url, + access_key=self._settings.s3_access_key, + secret_key=self._settings.s3_secret_key, + bucket=self._settings.s3_bucket, + region=self._settings.s3_region, + ) + self._provider: LLMProvider | None = provider + self._owns_provider = provider is None + + async def _provider_instance(self) -> LLMProvider: + if self._provider is None: + self._provider = build_llm_provider(self._settings) + return self._provider + + async def aclose(self) -> None: + if self._provider is not None and self._owns_provider: + await self._provider.aclose() + + async def _db_state( + self, session: AsyncSession, document_id: uuid.UUID + ) -> tuple[str | None, str | None]: + row = await session.execute( + text("SELECT status, filename FROM documents WHERE id = :d FOR UPDATE"), + {"d": document_id}, + ) + result = row.first() + if result is None: + return None, None + return (None if result[0] is None else str(result[0]), str(result[1])) + + async def handle(self, payload: DocumentExtracted) -> None: + async with self._session_factory() as session: + status, filename = await self._db_state(session, payload.document_id) + if status is None: + log.warning("document_not_found", document_id=str(payload.document_id)) + return + if status in DOC_TERMINAL: + log.info("document_already_terminal", status=status) + return + + await session.execute( + text("UPDATE documents SET status = 'analyzing', stage = 'llm' WHERE id = :d"), + {"d": payload.document_id}, + ) + await session.execute( + text( + "UPDATE jobs SET status = 'running', attempts = attempts + 1 " + "WHERE document_id = :d AND queue = 'analyze'" + ), + {"d": payload.document_id}, + ) + await session.commit() + + text_bytes = await self._storage.get(payload.extracted_s3_key) + contract_text = text_bytes.decode("utf-8") + source_name = filename or payload.extracted_s3_key + + provider = await self._provider_instance() + with analyze_duration.time(): + result = await provider.analyze(contract_text, checklist=checklist_for_prompt()) + + llm_tokens.labels(kind="prompt").inc(result.prompt_tokens) + llm_tokens.labels(kind="eval").inc(result.eval_tokens) + if result.fell_back: + llm_fell_back.inc() + + chunks = max( + 1, + (len(contract_text) + self._settings.chunk_size_chars - 1) + // self._settings.chunk_size_chars, + ) + metrics = RenderMetrics( + chars=len(contract_text), + chunks=chunks, + findings=len(result.findings), + prompt_tokens=result.prompt_tokens, + eval_tokens=result.eval_tokens, + latency_sec=result.latency_sec, + models_used=set(result.models_used), + fell_back=result.fell_back, + repaired=result.repaired, + ) + markdown = render_markdown(result.findings, source_name, metrics) + content_json = json.dumps( + {"findings": [f.model_dump() for f in result.findings]}, + ensure_ascii=False, + ) + + async with self._session_factory() as session: + await session.execute( + text( + "INSERT INTO reports " + "(document_id, content_json, markdown, model_used, " + " prompt_tokens, eval_tokens, latency_ms) " + "VALUES (:d, CAST(:content AS jsonb), :md, :model, :pt, :et, :lat) " + "ON CONFLICT (document_id) DO UPDATE SET " + " content_json = EXCLUDED.content_json, " + " markdown = EXCLUDED.markdown, " + " model_used = EXCLUDED.model_used, " + " prompt_tokens = EXCLUDED.prompt_tokens, " + " eval_tokens = EXCLUDED.eval_tokens, " + " latency_ms = EXCLUDED.latency_ms" + ), + { + "d": payload.document_id, + "content": content_json, + "md": markdown, + "model": result.model_used, + "pt": result.prompt_tokens, + "et": result.eval_tokens, + "lat": int(result.latency_sec * 1000), + }, + ) + await session.execute( + text("UPDATE documents SET status = 'done', stage = 'done' WHERE id = :d"), + {"d": payload.document_id}, + ) + await session.execute( + text( + "UPDATE jobs SET status = 'done' WHERE document_id = :d AND queue = 'analyze'" + ), + {"d": payload.document_id}, + ) + await session.commit() + + log.info( + "analyze_success", + document_id=str(payload.document_id), + findings=len(result.findings), + ) + + def classify(self, exc: BaseException) -> FailureClass: + from ..core.llm.ollama_cloud import ( + LLMConfigError, + LLMError, + LLMQuotaError, + LLMUnavailableError, + ) + + if isinstance(exc, LLMQuotaError): + return "llm_quota" + if isinstance(exc, LLMConfigError): + return "infra" + if isinstance(exc, LLMUnavailableError): + return "llm_timeout" + if isinstance(exc, LLMError): + return "llm_invalid_output" + return "infra" + + async def on_failure( + self, + payload: DocumentExtracted, + failure_class: FailureClass, + attempt: int, + error: str, + ) -> None: + mq_failed.labels(queue="analyze", failure_class=failure_class).inc() + async with self._session_factory() as session: + await session.execute( + text( + "UPDATE jobs SET status = 'retrying', attempts = :a, " + "last_failure_class = :fc, last_error = :err " + "WHERE document_id = :d AND queue = 'analyze'" + ), + { + "a": attempt, + "fc": failure_class, + "err": error[:1000], + "d": payload.document_id, + }, + ) + await session.commit() + + async def on_terminal_failure( + self, payload: DocumentExtracted, failure_class: FailureClass, error: str + ) -> None: + mq_failed.labels(queue="analyze", failure_class=failure_class).inc() + async with self._session_factory() as session: + await session.execute( + text( + "UPDATE jobs SET status = 'dlq', dlq = TRUE, " + "last_failure_class = :fc, last_error = :err " + "WHERE document_id = :d AND queue = 'analyze'" + ), + { + "fc": failure_class, + "err": error[:1000], + "d": payload.document_id, + }, + ) + await session.execute( + text("UPDATE documents SET status = 'failed', stage = :stage WHERE id = :d"), + {"stage": failure_class, "d": payload.document_id}, + ) + await refund_credit( + session, + payload.document_id, + failure_class, + self._settings.refund_policy, + ) + await session.commit() + + log.warning( + "analyze_terminal_failure", + document_id=str(payload.document_id), + failure_class=failure_class, + ) diff --git a/src/contract_check/worker_extract/__init__.py b/src/contract_check/worker_extract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/contract_check/worker_extract/__main__.py b/src/contract_check/worker_extract/__main__.py new file mode 100644 index 0000000..fc108dc --- /dev/null +++ b/src/contract_check/worker_extract/__main__.py @@ -0,0 +1,62 @@ +"""worker-extract entrypoint: connects to RabbitMQ and runs the extract consumer.""" + +from __future__ import annotations + +import asyncio +import signal + +from ..core.config import get_settings +from ..core.logging import bind_context, configure_logging, get_logger +from ..core.metrics import start_metrics_server +from ..core.sentry import init_sentry +from ..core.telemetry import setup_telemetry, shutdown_telemetry +from .consumer import ExtractConsumer + +log = get_logger(__name__) + + +async def main() -> None: + settings = get_settings() + configure_logging( + settings.log_level, + json_output=settings.json_logs, + service="worker-extract", + env=settings.env, + ) + bind_context(service="worker-extract", env=settings.env) + init_sentry("worker-extract") + setup_telemetry("worker-extract") + + start_metrics_server(9101) + + consumer = ExtractConsumer( + url=settings.rabbitmq_url, + origin="worker-extract", + prefetch=settings.mq_prefetch_extract, + max_attempts=settings.mq_max_attempts, + retry_base_ms=settings.mq_retry_base_ms, + ) + await consumer.connect() + + loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop_event.set) + + consumer_task = asyncio.create_task(consumer.run()) + stop_task = asyncio.create_task(stop_event.wait()) + + log.info("worker_extract_started", prefetch=settings.mq_prefetch_extract) + try: + await asyncio.wait( + {consumer_task, stop_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + await consumer.stop() + shutdown_telemetry() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/contract_check/worker_extract/consumer.py b/src/contract_check/worker_extract/consumer.py new file mode 100644 index 0000000..7ae18a7 --- /dev/null +++ b/src/contract_check/worker_extract/consumer.py @@ -0,0 +1,61 @@ +"""worker-extract consumer: wires the extract handler into the RabbitMQ base.""" + +from __future__ import annotations + +from ..core.db.enums import FailureClass +from ..core.db.session import create_session_factory +from ..core.logging import get_logger +from ..core.metrics import extract_duration +from ..core.mq.consumer import Consumer +from ..core.mq.messages import DocumentUploaded +from ..core.mq.topology import RK_ANALYZE +from .handler import ExtractHandler + +log = get_logger(__name__) + + +class ExtractConsumer(Consumer[DocumentUploaded]): + """Consumes `extract.q`, extracts text/OCR, publishes to `analyze.q`.""" + + queue: str = "extract.q" + routing_key: str = "extract" + message_model = DocumentUploaded + + def __init__( + self, + url: str, + *, + origin: str, + prefetch: int, + max_attempts: int, + retry_base_ms: int, + ) -> None: + super().__init__( + url, + origin=origin, + prefetch=prefetch, + max_attempts=max_attempts, + retry_base_ms=retry_base_ms, + ) + self._session_factory = create_session_factory() + self._handler = ExtractHandler( + session_factory=self._session_factory, + publish_routing_key=RK_ANALYZE, + ) + + def classify(self, exc: BaseException) -> FailureClass: + return self._handler.classify(exc) + + @extract_duration.time() + async def handle(self, payload: DocumentUploaded) -> None: + await self._handler.handle(payload) + + async def on_failure( + self, payload: DocumentUploaded, failure_class: FailureClass, attempt: int, error: str + ) -> None: + await self._handler.on_failure(payload, failure_class, attempt, error) + + async def on_dlq( + self, payload: DocumentUploaded, failure_class: FailureClass, error: str + ) -> None: + await self._handler.on_terminal_failure(payload, failure_class, error) diff --git a/src/contract_check/worker_extract/extract_document.py b/src/contract_check/worker_extract/extract_document.py new file mode 100644 index 0000000..a624057 --- /dev/null +++ b/src/contract_check/worker_extract/extract_document.py @@ -0,0 +1,26 @@ +"""Facade re-exporting extraction + OCR helpers for the worker.""" + +from __future__ import annotations + +from ..core.analysis.extractor import ExtractionError, extract_text +from ..core.analysis.ocr import OCRError, ocr_pdf + +__all__ = ["ExtractionError", "OCRError", "extract_document"] + + +def extract_document(path: str) -> str: + """Try direct extraction; fall back to OCR for likely scans. + + Mirrors docs/ARCHITECTURE.md §16 worker-extract pseudocode: + 1. extract_text (PDF text layer / DOCX) + 2. if ExtractionError due to short text, try OCR + 3. if OCR still short, raise ExtractionError (failure_class extraction_failed) + """ + try: + return extract_text(path) + except ExtractionError: + # Likely scan or image-only PDF; attempt OCR once. + return ocr_pdf(path) + + +__all__.append("extract_document") diff --git a/src/contract_check/worker_extract/handler.py b/src/contract_check/worker_extract/handler.py new file mode 100644 index 0000000..58bb8bc --- /dev/null +++ b/src/contract_check/worker_extract/handler.py @@ -0,0 +1,240 @@ +"""Extract worker handler: download blob → extract/OCR → upload text → publish. + +The handler is intentionally separate from the consumer so it can be tested +in-process without spinning up a real RabbitMQ consumer. It owns: + - idempotency checks against documents.status + - status transitions (queued → extracting → ocr) + - MinIO download/upload + - extraction/OCR via core.analysis + - publishing DocumentExtracted to analyze.q + - updating the jobs row, recording failure class, and refund-on-DLQ. +""" + +from __future__ import annotations + +import asyncio +import tempfile +import uuid +from pathlib import Path +from typing import TYPE_CHECKING + +from sqlalchemy import text + +from ..core.config import get_settings +from ..core.credits import refund_credit +from ..core.db.enums import DOC_TERMINAL, FailureClass +from ..core.logging import get_logger +from ..core.metrics import extract_duration, mq_failed, mq_published +from ..core.mq.messages import DocumentExtracted, DocumentUploaded +from ..core.mq.publisher import Publisher +from ..core.s3 import extracted_key +from ..core.s3.minio_storage import MinioStorage + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +log = get_logger(__name__) + + +class ExtractHandler: + """Business logic for worker-extract.""" + + def __init__( + self, + *, + session_factory: async_sessionmaker[AsyncSession], + publish_routing_key: str = "analyze", + ) -> None: + self._session_factory = session_factory + self._publish_routing_key = publish_routing_key + self._settings = get_settings() + self._storage = MinioStorage.from_endpoint_url( + endpoint_url=self._settings.s3_endpoint_url, + access_key=self._settings.s3_access_key, + secret_key=self._settings.s3_secret_key, + bucket=self._settings.s3_bucket, + region=self._settings.s3_region, + ) + self._publisher: Publisher | None = None + + async def _publisher_instance(self) -> Publisher: + if self._publisher is None: + self._publisher = Publisher(self._settings.rabbitmq_url, origin="worker-extract") + await self._publisher.connect() + return self._publisher + + async def _db_status(self, session: AsyncSession, document_id: uuid.UUID) -> str | None: + row = await session.execute( + text("SELECT status FROM documents WHERE id = :d FOR UPDATE"), + {"d": document_id}, + ) + result = row.first() + return str(result[0]) if result else None + + async def handle(self, payload: DocumentUploaded) -> None: + async with self._session_factory() as session: + current = await self._db_status(session, payload.document_id) + if current is None: + log.warning("document_not_found", document_id=str(payload.document_id)) + return + if current in DOC_TERMINAL: + log.info("document_already_terminal", status=current) + return + + await session.execute( + text( + "UPDATE documents SET status = 'extracting', stage = 'downloading' " + "WHERE id = :d" + ), + {"d": payload.document_id}, + ) + await session.execute( + text( + "UPDATE jobs SET status = 'running', attempts = attempts + 1 " + "WHERE document_id = :d AND queue = 'extract'" + ), + {"d": payload.document_id}, + ) + await session.commit() + + with extract_duration.time(): + extracted_text, ocr_used = await self._extract_text(payload) + + ext_key = extracted_key(str(payload.user_id), str(payload.document_id)) + text_bytes = extracted_text.encode("utf-8") + + await self._storage.put(ext_key, text_bytes, content_type="text/plain; charset=utf-8") + + msg = DocumentExtracted( + correlation_id=payload.correlation_id, + document_id=payload.document_id, + user_id=payload.user_id, + extracted_s3_key=ext_key, + char_count=len(extracted_text), + ocr_used=ocr_used, + attempt=payload.attempt, + ) + + publisher = await self._publisher_instance() + await publisher.publish(msg, routing_key=self._publish_routing_key) + mq_published.labels(queue="analyze").inc() + + async with self._session_factory() as session: + await session.execute( + text( + "UPDATE documents SET status = 'analyzing', stage = 'queued_analyze', " + "extracted_s3_key = :key WHERE id = :d" + ), + {"key": ext_key, "d": payload.document_id}, + ) + await session.execute( + text( + "UPDATE jobs SET status = 'done' WHERE document_id = :d AND queue = 'extract'" + ), + {"d": payload.document_id}, + ) + await session.commit() + + log.info( + "extract_success", + document_id=str(payload.document_id), + char_count=len(extracted_text), + ) + + async def _extract_text(self, payload: DocumentUploaded) -> tuple[str, bool]: + from ..core.analysis.extractor import ExtractionError, extract_text + from ..core.analysis.ocr import ocr_pdf + + data = await self._storage.get(payload.s3_key) + suffix = Path(payload.filename).suffix.lower() + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(data) + tmp_path = Path(tmp.name) + + try: + try: + text = await asyncio.to_thread(extract_text, str(tmp_path)) + return text, False + except ExtractionError: + await self._update_stage(payload.document_id, "ocr") + text = await asyncio.to_thread(ocr_pdf, str(tmp_path)) + return text, True + finally: + tmp_path.unlink(missing_ok=True) + + async def _update_stage(self, document_id: uuid.UUID, stage: str) -> None: + from sqlalchemy import text + + async with self._session_factory() as session: + await session.execute( + text("UPDATE documents SET stage = :stage WHERE id = :d"), + {"stage": stage, "d": document_id}, + ) + await session.commit() + + def classify(self, exc: BaseException) -> FailureClass: + from ..core.analysis.extractor import ExtractionError + from ..core.analysis.ocr import OCRError + + if isinstance(exc, ExtractionError): + return "extraction_failed" + if isinstance(exc, OCRError): + return "ocr_failed" + return "infra" + + async def on_failure( + self, payload: DocumentUploaded, failure_class: FailureClass, attempt: int, error: str + ) -> None: + from sqlalchemy import text + + mq_failed.labels(queue="extract", failure_class=failure_class).inc() + async with self._session_factory() as session: + await session.execute( + text( + "UPDATE jobs SET status = 'retrying', attempts = :a, " + "last_failure_class = :fc, last_error = :err " + "WHERE document_id = :d AND queue = 'extract'" + ), + { + "a": attempt, + "fc": failure_class, + "err": error[:1000], + "d": payload.document_id, + }, + ) + await session.commit() + + async def on_terminal_failure( + self, payload: DocumentUploaded, failure_class: FailureClass, error: str + ) -> None: + mq_failed.labels(queue="extract", failure_class=failure_class).inc() + async with self._session_factory() as session: + await session.execute( + text( + "UPDATE jobs SET status = 'dlq', dlq = TRUE, " + "last_failure_class = :fc, last_error = :err " + "WHERE document_id = :d AND queue = 'extract'" + ), + { + "fc": failure_class, + "err": error[:1000], + "d": payload.document_id, + }, + ) + await session.execute( + text("UPDATE documents SET status = 'failed', stage = :stage WHERE id = :d"), + {"stage": failure_class, "d": payload.document_id}, + ) + await refund_credit( + session, + payload.document_id, + failure_class, + self._settings.refund_policy, + ) + await session.commit() + + log.warning( + "extract_terminal_failure", + document_id=str(payload.document_id), + failure_class=failure_class, + ) diff --git a/srv/api/Dockerfile b/srv/api/Dockerfile new file mode 100644 index 0000000..1712289 --- /dev/null +++ b/srv/api/Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1 + +# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY README.md ./ +COPY alembic.ini ./ +COPY migrations ./migrations +COPY src ./src + +# Install only the api group (core + db/mq/s3/obs + fastapi/uvicorn). +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-default-groups --group api --no-install-project && \ + uv pip install --no-deps . + +# ─── Stage 2: lean runtime ───────────────────────────────────────────────── +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH=/app/.venv/bin:$PATH + +WORKDIR /app + +COPY --from=builder /app/.venv /app/.venv +COPY alembic.ini ./ +COPY migrations ./migrations + +EXPOSE 8000 9100 +CMD ["python", "-m", "contract_check.api"] diff --git a/srv/bot/Dockerfile b/srv/bot/Dockerfile new file mode 100644 index 0000000..466c82e --- /dev/null +++ b/srv/bot/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 + +# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY README.md ./ +COPY src ./src + +# Install only the bot group (core + aiogram). No DB/MQ/S3/LLM/OCR drivers — +# this is the leanest service image; it speaks HTTP to the api exclusively. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-default-groups --group bot --no-install-project && \ + uv pip install --no-deps . + +# ─── Stage 2: lean runtime ───────────────────────────────────────────────── +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH=/app/.venv/bin:$PATH + +WORKDIR /app + +COPY --from=builder /app/.venv /app/.venv + +CMD ["python", "-m", "contract_check.bot"] diff --git a/srv/prototype/Dockerfile b/srv/prototype/Dockerfile new file mode 100644 index 0000000..128fe17 --- /dev/null +++ b/srv/prototype/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── +# Standalone stage-0 benchmark CLI (PDF/DOCX → text → Ollama Cloud → markdown). +# No DB/MQ/S3 — kept as a reference benchmark image, not a production service. +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY README.md ./ +COPY src ./src + +# Install only the prototype group (core + pymupdf/python-docx). +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-default-groups --group prototype --no-install-project && \ + uv pip install --no-deps . + +# ─── Stage 2: lean runtime ───────────────────────────────────────────────── +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH=/app/.venv/bin:$PATH + +WORKDIR /app + +COPY --from=builder /app/.venv /app/.venv + +CMD ["python", "-m", "contract_check.prototype"] diff --git a/srv/worker-analyze/Dockerfile b/srv/worker-analyze/Dockerfile new file mode 100644 index 0000000..69b8cae --- /dev/null +++ b/srv/worker-analyze/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY README.md ./ +COPY src ./src + +# Install only the analyze group (core + db/mq/s3/obs + otel-httpx). No +# tesseract/pymupdf — this image is the leanest LLM/I/O worker. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-default-groups --group analyze --no-install-project && \ + uv pip install --no-deps . + +# ─── Stage 2: lean runtime ───────────────────────────────────────────────── +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH=/app/.venv/bin:$PATH + +WORKDIR /app + +COPY --from=builder /app/.venv /app/.venv + +EXPOSE 9102 +CMD ["python", "-m", "contract_check.worker_analyze"] diff --git a/srv/worker-extract/Dockerfile b/srv/worker-extract/Dockerfile new file mode 100644 index 0000000..c8a9ab9 --- /dev/null +++ b/srv/worker-extract/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 + +# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY README.md ./ +COPY src ./src + +# Install only the extract group (core + db/mq/s3/obs + pymupdf/tesseract). +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-default-groups --group extract --no-install-project && \ + uv pip install --no-deps . + +# ─── Stage 2: runtime with tesseract-ocr + language packs ──────────────────── +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH=/app/.venv/bin:$PATH + +WORKDIR /app + +# Tesseract and Russian/English language data for scanned PDFs. +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr \ + tesseract-ocr-rus \ + tesseract-ocr-eng \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/.venv /app/.venv + +EXPOSE 9101 +CMD ["python", "-m", "contract_check.worker_extract"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..31d0e98 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +"""Shared pytest fixtures. + +Unit tests stay fast and dependency-free (no DB/MQ/S3). Integration tests use +testcontainers and are marked `@pytest.mark.integration` (deselected by the +default `pytest -q` run). +""" + +from __future__ import annotations + +import os + +# Default test env: prevent Settings() from failing on required fields when no +# .env is present. Individual tests that build Settings override as needed. +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://x:x@localhost:5432/x") +os.environ.setdefault("RABBITMQ_URL", "amqp://x:x@localhost:5672//") +os.environ.setdefault("S3_ENDPOINT_URL", "http://localhost:9000") +os.environ.setdefault("S3_ACCESS_KEY", "test") +os.environ.setdefault("S3_SECRET_KEY", "test") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..5a5db83 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,140 @@ +"""Integration-test fixtures using the running Docker Compose infrastructure. + +Run `docker compose up -d` before executing integration tests. Migrations run +once per session; a service token is seeded so adapter-style endpoints can +authenticate. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import TYPE_CHECKING + +import httpx +import pytest +import pytest_asyncio +from asgi_lifespan import LifespanManager +from sqlalchemy import text + +from contract_check.api.app import create_app +from contract_check.core.config import get_settings +from contract_check.core.db.session import create_session_factory +from contract_check.core.tokens import hash_token + +if TYPE_CHECKING: + pass + +pytestmark = pytest.mark.integration + +_BOT_SERVICE_TOKEN = "it-test-bot-token" + +_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check" +_AMQP_URL = "amqp://contract_check:contract_check@localhost:5672//" +_S3_URL = "http://localhost:9000" + + +def _set_env() -> None: + env_vars = { + "DATABASE_URL": _DB_URL, + "RABBITMQ_URL": _AMQP_URL, + "S3_ENDPOINT_URL": _S3_URL, + "S3_ACCESS_KEY": "contract_check", + "S3_SECRET_KEY": "contract_check", + "S3_BUCKET": "contract-check-docs", + "REDIS_URL": "redis://localhost:17379/0", + "OLLAMA_HOST": "http://localhost", + "OLLAMA_API_KEY": "test", + "JWT_SECRET": "it-test-jwt-secret-not-for-production", + "JWT_ALGORITHM": "HS256", + "JWT_ACCESS_TTL_MINUTES": "1440", + "TELEGRAM_BOT_TOKEN": "it-test-bot-token:it-test-secret", + } + for k, v in env_vars.items(): + os.environ[k] = v + + +@pytest.fixture(scope="session") +def infra() -> Iterator[dict[str, str]]: + _set_env() + get_settings.cache_clear() + + repo_root = Path(__file__).resolve().parents[2] + subprocess.run( + [sys.executable, "-m", "alembic", "-c", str(repo_root / "alembic.ini"), "upgrade", "head"], + cwd=str(repo_root), + env={**os.environ}, + check=True, + capture_output=False, + ) + + factory = create_session_factory() + + async def _seed() -> None: + async with factory() as session: + await session.execute( + text( + "INSERT INTO service_tokens (name, token_hash, adapter) " + "VALUES (:name, :hash, 'bot') " + "ON CONFLICT (name) DO UPDATE SET " + " token_hash = EXCLUDED.token_hash, revoked = FALSE" + ), + {"name": "bot-test", "hash": hash_token(_BOT_SERVICE_TOKEN)}, + ) + await session.commit() + + import asyncio + + asyncio.run(_seed()) + + yield { + "database_url": _DB_URL, + "rabbitmq_url": _AMQP_URL, + "s3_endpoint_url": _S3_URL, + "s3_access_key": "contract_check", + "s3_secret_key": "contract_check", + "token": _BOT_SERVICE_TOKEN, + } + + get_settings.cache_clear() + + +@pytest_asyncio.fixture +async def client(infra: dict[str, str]) -> httpx.AsyncClient: + get_settings.cache_clear() + _set_env() + get_settings() + app = create_app() + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as c: + async with LifespanManager(app): + yield c + get_settings.cache_clear() + + +@pytest.fixture +def auth_header(infra: dict[str, str]) -> dict[str, str]: + return {"Authorization": f"Bearer {infra['token']}"} + + +async def user_token(client: httpx.AsyncClient, infra: dict[str, str], telegram_id: int) -> str: + """Call /auth/telegram/bot and return a user JWT for the given telegram_id.""" + r = await client.post( + "/api/v1/auth/telegram/bot", + json={"telegram_id": telegram_id}, + headers={"Authorization": f"Bearer {infra['token']}"}, + ) + assert r.status_code == 200, f"auth failed: {r.status_code} {r.text}" + return r.json()["access_token"] + + +@pytest.fixture +async def db_session(): + factory = create_session_factory() + async with factory() as session: + yield session + await session.close() diff --git a/tests/integration/test_analyze_worker.py b/tests/integration/test_analyze_worker.py new file mode 100644 index 0000000..af8551d --- /dev/null +++ b/tests/integration/test_analyze_worker.py @@ -0,0 +1,265 @@ +"""Integration tests for worker-analyze. + +Uses the same Docker Compose infra as test_upload_pipeline / test_extract_worker: +real Postgres, RabbitMQ, MinIO. The LLM is respx-mocked at a fake Ollama host. + +Scenarios: + 1. Happy path: DocumentExtracted -> report saved (JSONB+markdown), status=done, + disclaimer present, jobs row done. + 2. Terminal failure: LLM quota error -> on_terminal_failure refunds + DLQ state. +""" + +from __future__ import annotations + +import json +import uuid +from typing import TYPE_CHECKING + +import httpx +import pytest +import respx +from sqlalchemy import text + +from contract_check.core.db.session import create_session_factory +from contract_check.core.llm.ollama_cloud import LLMQuotaError, OllamaCloudProvider +from contract_check.core.mq.messages import DocumentExtracted +from contract_check.core.s3 import extracted_key +from contract_check.core.s3.minio_storage import MinioStorage +from contract_check.worker_analyze.handler import AnalyzeHandler + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +pytestmark = pytest.mark.integration + +HOST = "https://ollama.test" +URL = f"{HOST}/api/chat" + +_VALID_FINDINGS = { + "findings": [ + { + "checklist_id": "penalties", + "severity": "high", + "quote": "Штраф 0,5% за каждый день просрочки", + "section_ref": "п. 6.3", + "risk": "Высокая неустойка", + "recommendation": "Ограничить cap", + } + ] +} + + +def _chat_body(content: str, model: str = "qwen2.5:14b") -> dict[str, object]: + return { + "model": model, + "message": {"content": content}, + "prompt_eval_count": 10, + "eval_count": 20, + } + + +def _storage(infra: dict[str, str]) -> MinioStorage: + return MinioStorage.from_endpoint_url( + endpoint_url=infra["s3_endpoint_url"], + access_key=infra["s3_access_key"], + secret_key=infra["s3_secret_key"], + bucket="contract-check-docs", + ) + + +def _session_factory(infra: dict[str, str]) -> async_sessionmaker[AsyncSession]: + return create_session_factory() + + +def _provider() -> OllamaCloudProvider: + return OllamaCloudProvider( + host=HOST, + api_key="key", + model="qwen2.5:14b", + fallback_model=None, + max_concurrency=1, + chunk_size=10000, + ) + + +async def _seed_document( + infra: dict[str, str], *, telegram_id: int, credits: int, document_id: uuid.UUID +) -> tuple[uuid.UUID, int, str, str]: + """Insert user + a post-extraction document; return (user_id, text, ext_key, s3_key).""" + sess = _session_factory(infra) + store = _storage(infra) + async with sess() as session: + result = await session.execute( + text( + "INSERT INTO users (telegram_id, credits_left) VALUES (:t, :c) " + "ON CONFLICT (telegram_id) DO UPDATE SET credits_left = :c " + "RETURNING id, credits_left" + ), + {"t": telegram_id, "c": credits}, + ) + row = result.first() + assert row is not None + user_id, initial_credits = row + await session.commit() + + ext_key = extracted_key(str(user_id), str(document_id)) + s3_key = f"users/{user_id}/docs/{document_id}.pdf" + contract_text = "Договор поставки. " * 50 + await store.put( + ext_key, contract_text.encode("utf-8"), content_type="text/plain; charset=utf-8" + ) + + async with sess() as session: + await session.execute( + text( + "INSERT INTO documents " + "(id, user_id, s3_key, extracted_s3_key, filename, mime, bytes, " + " status, stage) " + "VALUES (:id, :uid, :s3, :ext, :fn, :mime, :bytes, " + " 'analyzing', 'queued_analyze')" + ), + { + "id": document_id, + "uid": user_id, + "s3": s3_key, + "ext": ext_key, + "fn": "contract.pdf", + "mime": "application/pdf", + "bytes": len(contract_text), + }, + ) + await session.execute( + text( + "INSERT INTO jobs (document_id, correlation_id, queue, status) " + "VALUES (:did, :cid, 'analyze', 'pending')" + ), + {"did": document_id, "cid": uuid.uuid4()}, + ) + await session.commit() + + return user_id, initial_credits, ext_key, contract_text + + +async def test_analyze_worker_saves_report_and_marks_done( + infra: dict[str, str], +) -> None: + sess = _session_factory(infra) + document_id = uuid.uuid4() + correlation_id = uuid.uuid4() + user_id, _initial, ext_key, contract_text = await _seed_document( + infra, telegram_id=222_333_444, credits=5, document_id=document_id + ) + + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + mock.post(URL).mock( + return_value=httpx.Response(200, json=_chat_body(json.dumps(_VALID_FINDINGS))) + ) + await AnalyzeHandler(session_factory=sess, provider=provider).handle( + DocumentExtracted( + correlation_id=correlation_id, + document_id=document_id, + user_id=user_id, + extracted_s3_key=ext_key, + char_count=len(contract_text), + ocr_used=False, + ) + ) + + async with sess() as session: + report = await session.execute( + text( + "SELECT markdown, model_used, prompt_tokens, eval_tokens " + "FROM reports WHERE document_id = :d" + ), + {"d": document_id}, + ) + row = report.first() + assert row is not None + markdown, model_used, prompt_tokens, eval_tokens = row + assert "Дисклеймер" in markdown + assert "Штраф 0,5%" in markdown + assert model_used == "qwen2.5:14b" + assert prompt_tokens == 10 + assert eval_tokens == 20 + + doc = await session.execute( + text("SELECT status, stage FROM documents WHERE id = :d"), + {"d": document_id}, + ) + status, stage = doc.one() + assert status == "done" + assert stage == "done" + + job = await session.execute( + text("SELECT status FROM jobs WHERE document_id = :d AND queue = 'analyze'"), + {"d": document_id}, + ) + assert job.scalar_one() == "done" + + +async def test_analyze_worker_terminal_failure_refunds_and_dlqs( + infra: dict[str, str], +) -> None: + sess = _session_factory(infra) + document_id = uuid.uuid4() + correlation_id = uuid.uuid4() + user_id, initial_credits, ext_key, contract_text = await _seed_document( + infra, telegram_id=555_666_777, credits=3, document_id=document_id + ) + + async with _provider() as provider: + handler = AnalyzeHandler(session_factory=sess, provider=provider) + with respx.mock(base_url=HOST) as mock: + mock.post(URL).mock(return_value=httpx.Response(429, json={"error": "quota"})) + with pytest.raises(LLMQuotaError): + await handler.handle( + DocumentExtracted( + correlation_id=correlation_id, + document_id=document_id, + user_id=user_id, + extracted_s3_key=ext_key, + char_count=len(contract_text), + ocr_used=False, + ) + ) + + await handler.on_terminal_failure( + DocumentExtracted( + correlation_id=correlation_id, + document_id=document_id, + user_id=user_id, + extracted_s3_key=ext_key, + char_count=len(contract_text), + ocr_used=False, + ), + "llm_quota", + "test terminal failure", + ) + + async with sess() as session: + doc = await session.execute( + text("SELECT status, refunded FROM documents WHERE id = :d"), + {"d": document_id}, + ) + status, refunded = doc.one() + assert status == "failed" + assert refunded is True + + job = await session.execute( + text( + "SELECT status, dlq, last_failure_class FROM jobs " + "WHERE document_id = :d AND queue = 'analyze'" + ), + {"d": document_id}, + ) + j_status, j_dlq, j_class = job.one() + assert j_status == "dlq" + assert j_dlq is True + assert j_class == "llm_quota" + + credits = await session.execute( + text("SELECT credits_left FROM users WHERE id = :u"), + {"u": user_id}, + ) + assert credits.scalar_one() == initial_credits + 1 diff --git a/tests/integration/test_auth_flow.py b/tests/integration/test_auth_flow.py new file mode 100644 index 0000000..a69cc5a --- /dev/null +++ b/tests/integration/test_auth_flow.py @@ -0,0 +1,168 @@ +"""Auth integration tests: Telegram identity sources issue a common JWT. + +Run against the Docker Compose infrastructure (`docker compose up -d`). +""" + +from __future__ import annotations + +import hashlib +import hmac +import time +from urllib.parse import urlencode + +import httpx +import pytest +from sqlalchemy import text + +pytestmark = pytest.mark.integration + +_BOT_TOKEN = "it-test-bot-token:it-test-secret" +_SECRET_KEY = hmac.new( + _BOT_TOKEN.encode("utf-8"), + b"WebAppData", + hashlib.sha256, +).digest() + + +def _make_web_payload(telegram_id: int) -> dict[str, object]: + auth_date = int(time.time()) + data = {"id": telegram_id, "first_name": "Integration", "auth_date": auth_date} + data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(data.items())) + data["hash"] = hmac.new( + _SECRET_KEY, + data_check_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return data + + +def _make_init_data(telegram_id: int) -> str: + auth_date = int(time.time()) + user_json = f'{{"id":{telegram_id},"first_name":"Integration"}}' + params = {"user": user_json, "auth_date": str(auth_date)} + data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(params.items())) + params["hash"] = hmac.new( + _SECRET_KEY, + data_check_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return urlencode(params) + + +async def test_auth_telegram_bot_creates_user_and_issues_jwt( + client: httpx.AsyncClient, + infra: dict[str, str], + db_session, +) -> None: + telegram_id = 123_456_789 + + r = await client.post( + "/api/v1/auth/telegram/bot", + json={"telegram_id": telegram_id}, + headers={"Authorization": f"Bearer {infra['token']}"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["token_type"] == "bearer" + assert body["telegram_id"] == telegram_id + assert body["access_token"] + + # User row created. + result = await db_session.execute( + text("SELECT id, telegram_id FROM users WHERE telegram_id = :t"), + {"t": telegram_id}, + ) + row = result.first() + assert row is not None + assert str(row[0]) == body["user_id"] + + # JWT works on /me. + me = await client.get( + "/api/v1/me", + headers={"Authorization": f"Bearer {body['access_token']}"}, + ) + assert me.status_code == 200 + assert me.json()["telegram_id"] == telegram_id + + +async def test_auth_telegram_web_verifies_widget_payload( + client: httpx.AsyncClient, + infra: dict[str, str], +) -> None: + telegram_id = 222_333_444 + + r = await client.post( + "/api/v1/auth/telegram/web", + json=_make_web_payload(telegram_id), + ) + assert r.status_code == 200 + body = r.json() + assert body["telegram_id"] == telegram_id + assert body["access_token"] + + +async def test_auth_telegram_miniapp_verifies_init_data( + client: httpx.AsyncClient, + infra: dict[str, str], +) -> None: + telegram_id = 333_444_555 + + r = await client.post( + "/api/v1/auth/telegram/miniapp", + json={"init_data": _make_init_data(telegram_id)}, + ) + assert r.status_code == 200 + body = r.json() + assert body["telegram_id"] == telegram_id + assert body["access_token"] + + +async def test_auth_telegram_web_rejects_bad_hash( + client: httpx.AsyncClient, + infra: dict[str, str], +) -> None: + payload = _make_web_payload(444_555_666) + payload["hash"] = "0" * 64 + + r = await client.post("/api/v1/auth/telegram/web", json=payload) + assert r.status_code == 401 + + +async def test_protected_endpoint_rejects_missing_token( + client: httpx.AsyncClient, +) -> None: + r = await client.get("/api/v1/me") + assert r.status_code == 401 + + +async def test_protected_endpoint_rejects_service_token( + client: httpx.AsyncClient, + infra: dict[str, str], +) -> None: + r = await client.get( + "/api/v1/me", + headers={"Authorization": f"Bearer {infra['token']}"}, + ) + assert r.status_code == 401 + + +async def test_auth_me_introspects_jwt( + client: httpx.AsyncClient, + infra: dict[str, str], +) -> None: + telegram_id = 555_666_777 + r = await client.post( + "/api/v1/auth/telegram/bot", + json={"telegram_id": telegram_id}, + headers={"Authorization": f"Bearer {infra['token']}"}, + ) + token = r.json()["access_token"] + + introspect = await client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert introspect.status_code == 200 + body = introspect.json() + assert body["telegram_id"] == telegram_id + assert body["type"] == "access" diff --git a/tests/integration/test_b2b_api.py b/tests/integration/test_b2b_api.py new file mode 100644 index 0000000..3c16f6d --- /dev/null +++ b/tests/integration/test_b2b_api.py @@ -0,0 +1,202 @@ +"""B2B API integration tests: API-key auth, upload, report polling, management. + +Run against the Docker Compose infrastructure (`docker compose up -d`). +""" + +from __future__ import annotations + +import uuid +from pathlib import Path + +import aio_pika +import httpx +import pytest +from tests.integration.conftest import user_token + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def pdf_bytes(tmp_path: Path) -> bytes: + import pymupdf + + doc = pymupdf.open() + page = doc.new_page() + page.insert_text((72, 72), "Договор. Стороны обязуются.") + path = tmp_path / "contract.pdf" + doc.save(str(path)) + doc.close() + return path.read_bytes() + + +@pytest.fixture +async def api_key_client( + client: httpx.AsyncClient, + db_session, # noqa: ANN001 + infra: dict[str, str], +) -> tuple[httpx.AsyncClient, str, int]: + """Create a user, authenticate, create an API key, and return (client, key, telegram_id).""" + from sqlalchemy import text + + from contract_check.core.api_keys import hash_api_key + + telegram_id = 999000999 + await db_session.execute( + text( + "INSERT INTO users (telegram_id, credits_left) VALUES (:t, 10) " + "ON CONFLICT (telegram_id) DO UPDATE SET credits_left = 10" + ), + {"t": telegram_id}, + ) + # Clean any leftover key from a previous interrupted run. + await db_session.execute( + text( + "DELETE FROM api_keys " + "WHERE user_id = (SELECT id FROM users WHERE telegram_id = :t) " + "AND name = :name" + ), + {"t": telegram_id, "name": "integration-test-key"}, + ) + await db_session.commit() + + token = await user_token(client, infra, telegram_id) + + create_resp = await client.post( + "/api/v1/b2b/keys", + headers={"Authorization": f"Bearer {token}"}, + json={"name": "integration-test-key"}, + ) + assert create_resp.status_code == 201 + body = create_resp.json() + raw_key = body["api_key"] + + # Confirm hash is stored. + result = await db_session.execute( + text("SELECT id FROM api_keys WHERE key_hash = :h"), + {"h": hash_api_key(raw_key)}, + ) + assert result.first() is not None + + return client, raw_key, telegram_id + + +async def test_b2b_analyze_upload_returns_202_and_enqueues( + api_key_client: tuple[httpx.AsyncClient, str, int], + pdf_bytes: bytes, + infra: dict[str, str], +) -> None: + client, api_key, _telegram_id = api_key_client + + response = await client.post( + "/api/v1/analyze", + headers={"X-API-Key": api_key}, + files={"file": ("contract.pdf", pdf_bytes, "application/pdf")}, + ) + assert response.status_code == 202 + body = response.json() + document_id = uuid.UUID(body["document_id"]) + correlation_id = uuid.UUID(body["correlation_id"]) + assert body["credits_left"] == 9 + + # Verify a DocumentUploaded message landed on extract.q (drain leftovers). + connection = await aio_pika.connect_robust(infra["rabbitmq_url"]) + try: + channel = await connection.channel() + queue = await channel.get_queue("extract.q", ensure=False) + deadline = 10.0 + found = False + import time + + while deadline > 0: + start = time.monotonic() + message = await queue.get(timeout=deadline) + await message.ack() + msg_body = message.body.decode("utf-8") + if str(document_id) in msg_body: + assert str(correlation_id) in msg_body + found = True + break + deadline -= time.monotonic() - start + assert found, "expected DocumentUploaded message not found in extract.q" + finally: + await connection.close() + + +async def test_b2b_get_report_before_ready_returns_status( + api_key_client: tuple[httpx.AsyncClient, str, int], + pdf_bytes: bytes, +) -> None: + client, api_key, _telegram_id = api_key_client + + upload = await client.post( + "/api/v1/analyze", + headers={"X-API-Key": api_key}, + files={"file": ("contract.pdf", pdf_bytes, "application/pdf")}, + ) + assert upload.status_code == 202 + document_id = upload.json()["document_id"] + + poll = await client.get( + f"/api/v1/b2b/reports/{document_id}", + headers={"X-API-Key": api_key}, + ) + assert poll.status_code == 200 + body = poll.json() + assert body["document_id"] == document_id + assert body["status"] == "queued" + assert "stage" in body + + +async def test_b2b_missing_api_key_returns_401(client: httpx.AsyncClient) -> None: + response = await client.get("/api/v1/b2b/usage") + assert response.status_code == 401 + + +async def test_b2b_invalid_api_key_returns_401(client: httpx.AsyncClient) -> None: + response = await client.get( + "/api/v1/b2b/usage", + headers={"X-API-Key": "clearly-invalid-key"}, + ) + assert response.status_code == 401 + + +async def test_b2b_key_management_requires_user_jwt( + client: httpx.AsyncClient, +) -> None: + response = await client.post( + "/api/v1/b2b/keys", + json={"name": "no-auth-key"}, + ) + assert response.status_code == 401 + + +async def test_b2b_revoke_key_blocks_usage( + api_key_client: tuple[httpx.AsyncClient, str, int], + infra: dict[str, str], + pdf_bytes: bytes, +) -> None: + client, api_key, telegram_id = api_key_client + + token = await user_token(client, infra, telegram_id) + + # List keys to find id. + list_resp = await client.get( + "/api/v1/b2b/keys", + headers={"Authorization": f"Bearer {token}"}, + ) + assert list_resp.status_code == 200 + key_id = list_resp.json()[0]["id"] + + revoke = await client.post( + f"/api/v1/b2b/keys/{key_id}/revoke", + headers={"Authorization": f"Bearer {token}"}, + ) + assert revoke.status_code == 200 + + # Revoked key cannot upload. + response = await client.post( + "/api/v1/analyze", + headers={"X-API-Key": api_key}, + files={"file": ("contract.pdf", pdf_bytes, "application/pdf")}, + ) + assert response.status_code == 401 diff --git a/tests/integration/test_credits_db.py b/tests/integration/test_credits_db.py new file mode 100644 index 0000000..e79230f --- /dev/null +++ b/tests/integration/test_credits_db.py @@ -0,0 +1,137 @@ +"""Credits DB integration tests (requires real Postgres via testcontainers). + +Marked `integration`; not run by the default fast suite. Verifies the atomic +reserve, idempotent refund, and the race-safety of `reserve_credit`. +""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest +import pytest_asyncio +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from testcontainers.community.postgres import PostgresContainer + +from contract_check.core.credits import refund_credit, reserve_credit + +pytestmark = pytest.mark.integration + + +@pytest_asyncio.fixture +async def pg_session(): + with PostgresContainer("postgres:16-alpine") as pg: + url = pg.get_connection_url().replace("psycopg2", "asyncpg") + engine = create_async_engine(url) + # Create minimal schema in-memory (Postgres handles UUID/JSONB fine). + async with engine.begin() as conn: + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto")) + await conn.execute( + text( + "CREATE TABLE users (" + "id UUID PRIMARY KEY DEFAULT gen_random_uuid()," + "credits_left INT NOT NULL DEFAULT 0 CHECK (credits_left >= 0)" + ")" + ) + ) + await conn.execute( + text( + "CREATE TABLE documents (" + "id UUID PRIMARY KEY DEFAULT gen_random_uuid()," + "user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE," + "refunded BOOLEAN NOT NULL DEFAULT FALSE" + ")" + ) + ) + async with engine.connect() as conn: + async_session = AsyncSession(bind=conn) + yield async_session + await engine.dispose() + + +async def insert_user(session: AsyncSession, credits: int = 5) -> uuid.UUID: + result = await session.execute( + text("INSERT INTO users (credits_left) VALUES (:c) RETURNING id"), + {"c": credits}, + ) + user_id = result.scalar_one() + await session.commit() + return user_id + + +async def insert_doc(session: AsyncSession, user_id: uuid.UUID) -> uuid.UUID: + result = await session.execute( + text("INSERT INTO documents (user_id, refunded) VALUES (:u, FALSE) RETURNING id"), + {"u": user_id}, + ) + doc_id = result.scalar_one() + await session.commit() + return doc_id + + +async def credit_balance(session: AsyncSession, user_id: uuid.UUID) -> int: + result = await session.execute( + text("SELECT credits_left FROM users WHERE id = :u"), {"u": user_id} + ) + return int(result.scalar_one()) + + +async def test_reserve_credit_decrements_once(pg_session: AsyncSession) -> None: + u = await insert_user(pg_session, credits=2) + ok = await reserve_credit(pg_session, u) + assert ok is True + await pg_session.commit() + assert await credit_balance(pg_session, u) == 1 + + +async def test_reserve_credit_rejects_when_zero(pg_session: AsyncSession) -> None: + u = await insert_user(pg_session, credits=0) + ok = await reserve_credit(pg_session, u) + assert ok is False + + +async def test_reserve_credit_never_goes_negative(pg_session: AsyncSession) -> None: + u = await insert_user(pg_session, credits=1) + results = await asyncio.gather( + reserve_credit(pg_session, u), + reserve_credit(pg_session, u), + reserve_credit(pg_session, u), + ) + # The atomic UPDATE WHERE credits_left>0 serializes concurrent attempts. + assert sum(1 for r in results if r) == 1 + await pg_session.commit() + assert await credit_balance(pg_session, u) == 0 + + +async def test_refund_credit_idempotent(pg_session: AsyncSession) -> None: + u = await insert_user(pg_session, credits=0) + d = await insert_doc(pg_session, u) + + ok1 = await refund_credit(pg_session, d, "llm_quota", "all") + assert ok1 is True + await pg_session.commit() + assert await credit_balance(pg_session, u) == 1 + + ok2 = await refund_credit(pg_session, d, "llm_quota", "all") + assert ok2 is False # already refunded + await pg_session.commit() + assert await credit_balance(pg_session, u) == 1 + + +async def test_refund_credit_respects_infra_only(pg_session: AsyncSession) -> None: + u = await insert_user(pg_session, credits=0) + d = await insert_doc(pg_session, u) + + ok = await refund_credit(pg_session, d, "extraction_failed", "infra_only") + assert ok is False # user pays for garbage + await pg_session.commit() + assert await credit_balance(pg_session, u) == 0 + + # LLM failure is refunded under infra_only. + d2 = await insert_doc(pg_session, u) + ok2 = await refund_credit(pg_session, d2, "llm_quota", "infra_only") + assert ok2 is True + await pg_session.commit() + assert await credit_balance(pg_session, u) == 1 diff --git a/tests/integration/test_extract_worker.py b/tests/integration/test_extract_worker.py new file mode 100644 index 0000000..d03f8de --- /dev/null +++ b/tests/integration/test_extract_worker.py @@ -0,0 +1,369 @@ +"""Integration tests for worker-extract. + +Uses the same Docker Compose infra as test_upload_pipeline: + - real Postgres, RabbitMQ, MinIO + - alembic migrations + seeded service token (via conftest.py infra fixture) + +Scenarios: + 1. Happy path: DocumentUploaded → extracted text on analyze.q + status=analyzing. + 2. DOCX extraction also works end-to-end. + 3. Terminal failure path: bad PDF → extraction_failed → refund + document failed. +""" + +from __future__ import annotations + +import json +import time +import uuid +from pathlib import Path +from typing import TYPE_CHECKING + +import aio_pika +import pytest + +from contract_check.core.db.session import create_session_factory +from contract_check.core.mq.messages import DocumentUploaded +from contract_check.core.s3 import original_key +from contract_check.core.s3.minio_storage import MinioStorage +from contract_check.worker_extract.handler import ExtractHandler + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +pytestmark = pytest.mark.integration + + +def storage(infra: dict[str, str]) -> MinioStorage: + return MinioStorage.from_endpoint_url( + endpoint_url=infra["s3_endpoint_url"], + access_key=infra["s3_access_key"], + secret_key=infra["s3_secret_key"], + bucket="contract-check-docs", + ) + + +def session_factory(infra: dict[str, str]) -> async_sessionmaker[AsyncSession]: + return create_session_factory() + + +@pytest.fixture +def pdf_bytes(tmp_path: Path) -> bytes: + import pymupdf + + doc = pymupdf.open() + page = doc.new_page() + long_text = ( + "Договор. Стороны обязуются выполнять условия. " + "Сторона А обязуется передать товар. " + "Сторона Б обязуется оплатить товар в течение десяти банковских дней. " + "Ответственность сторон ограничена суммой договора. " + "Споры подлежат рассмотрению в арбитражном суде города Москвы." + ) + page.insert_htmlbox( + page.rect, + f'

{long_text}

', + ) + path = tmp_path / "contract.pdf" + doc.save(str(path)) + doc.close() + return path.read_bytes() + + +@pytest.fixture +def docx_bytes(tmp_path: Path) -> bytes: + from docx import Document + + doc = Document() + long_text = ( + "Договор. Стороны обязуются выполнять условия. " + "Сторона А обязуется передать товар. " + "Сторона Б обязуется оплатить товар в течение десяти банковских дней. " + "Ответственность сторон ограничена суммой договора. " + "Споры подлежат рассмотрению в арбитражном суде города Москвы." + ) + doc.add_paragraph(long_text) + path = tmp_path / "contract.docx" + doc.save(str(path)) + return path.read_bytes() + + +async def test_extract_worker_pdf_uploads_text_and_publishes_analyze( + infra: dict[str, str], + pdf_bytes: bytes, +) -> None: + from sqlalchemy import text + + telegram_id = 111_222_333 + user_id: uuid.UUID | None = None + sess_factory = session_factory(infra) + store = storage(infra) + async with sess_factory() as session: + result = await session.execute( + text( + "INSERT INTO users (telegram_id, credits_left) VALUES (:t, 5) " + "ON CONFLICT (telegram_id) DO UPDATE SET credits_left = 5 " + "RETURNING id" + ), + {"t": telegram_id}, + ) + user_id = result.scalar_one() + await session.commit() + assert user_id is not None + + document_id = uuid.uuid4() + correlation_id = uuid.uuid4() + s3_key = original_key(str(user_id), str(document_id), ".pdf") + await store.put(s3_key, pdf_bytes, content_type="application/pdf") + + async with sess_factory() as session: + await session.execute( + text( + "INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) " + "VALUES (:id, :uid, :s3, :fn, :mime, :bytes, 'queued')" + ), + { + "id": document_id, + "uid": user_id, + "s3": s3_key, + "fn": "contract.pdf", + "mime": "application/pdf", + "bytes": len(pdf_bytes), + }, + ) + await session.execute( + text( + "INSERT INTO jobs (document_id, correlation_id, queue, status) " + "VALUES (:did, :cid, 'extract', 'pending')" + ), + {"did": document_id, "cid": correlation_id}, + ) + await session.commit() + + handler = ExtractHandler(session_factory=sess_factory) + await handler.handle( + DocumentUploaded( + correlation_id=correlation_id, + document_id=document_id, + user_id=user_id, + s3_key=s3_key, + filename="contract.pdf", + mime="application/pdf", + ) + ) + + extracted_key_path = f"users/{user_id}/docs/{document_id}.txt" + text_data = await store.get(extracted_key_path) + assert "Договор" in text_data.decode("utf-8") + + connection = await aio_pika.connect_robust(infra["rabbitmq_url"]) + try: + channel = await connection.channel() + queue = await channel.get_queue("analyze.q", ensure=False) + deadline = 10.0 + found = False + while deadline > 0: + start = time.monotonic() + message = await queue.get(timeout=deadline) + await message.ack() + body = json.loads(message.body.decode("utf-8")) + if body["document_id"] == str(document_id): + assert body["correlation_id"] == str(correlation_id) + assert body["extracted_s3_key"] == extracted_key_path + assert body["char_count"] > 0 + assert body["ocr_used"] is False + found = True + break + deadline -= time.monotonic() - start + assert found, "expected DocumentExtracted message not found in analyze.q" + finally: + await connection.close() + + async with sess_factory() as session: + res = await session.execute( + text("SELECT status, stage, extracted_s3_key FROM documents WHERE id = :d"), + {"d": document_id}, + ) + row = res.first() + assert row is not None + status, stage, ext_key = row + assert status == "analyzing" + assert stage == "queued_analyze" + assert ext_key == extracted_key_path + + +async def test_extract_worker_docx_uploads_text( + infra: dict[str, str], + docx_bytes: bytes, +) -> None: + from sqlalchemy import text + + telegram_id = 444_555_666 + sess_factory = session_factory(infra) + store = storage(infra) + async with sess_factory() as session: + result = await session.execute( + text( + "INSERT INTO users (telegram_id, credits_left) VALUES (:t, 5) " + "ON CONFLICT (telegram_id) DO UPDATE SET credits_left = 5 " + "RETURNING id" + ), + {"t": telegram_id}, + ) + user_id = result.scalar_one() + await session.commit() + + document_id = uuid.uuid4() + correlation_id = uuid.uuid4() + s3_key = original_key(str(user_id), str(document_id), ".docx") + await store.put( + s3_key, + docx_bytes, + content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + async with sess_factory() as session: + await session.execute( + text( + "INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) " + "VALUES (:id, :uid, :s3, :fn, :mime, :bytes, 'queued')" + ), + { + "id": document_id, + "uid": user_id, + "s3": s3_key, + "fn": "contract.docx", + "mime": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "bytes": len(docx_bytes), + }, + ) + await session.execute( + text( + "INSERT INTO jobs (document_id, correlation_id, queue, status) " + "VALUES (:did, :cid, 'extract', 'pending')" + ), + {"did": document_id, "cid": correlation_id}, + ) + await session.commit() + + handler = ExtractHandler(session_factory=sess_factory) + await handler.handle( + DocumentUploaded( + correlation_id=correlation_id, + document_id=document_id, + user_id=user_id, + s3_key=s3_key, + filename="contract.docx", + mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + ) + + extracted_key_path = f"users/{user_id}/docs/{document_id}.txt" + text_data = await store.get(extracted_key_path) + assert "Стороны" in text_data.decode("utf-8") + + async with sess_factory() as session: + row = await session.execute( + text("SELECT status FROM documents WHERE id = :d"), + {"d": document_id}, + ) + assert row.scalar_one() == "analyzing" + + +async def test_extract_worker_terminal_failure_refunds_and_dlqs( + infra: dict[str, str], +) -> None: + from sqlalchemy import text + + telegram_id = 777_888_999 + sess_factory = session_factory(infra) + store = storage(infra) + async with sess_factory() as session: + result = await session.execute( + text( + "INSERT INTO users (telegram_id, credits_left) VALUES (:t, 3) " + "ON CONFLICT (telegram_id) DO UPDATE SET credits_left = 3 " + "RETURNING id, credits_left" + ), + {"t": telegram_id}, + ) + row = result.first() + assert row is not None + user_id, initial_credits = row + await session.commit() + + document_id = uuid.uuid4() + correlation_id = uuid.uuid4() + s3_key = original_key(str(user_id), str(document_id), ".pdf") + await store.put(s3_key, b"not a pdf", content_type="application/pdf") + + async with sess_factory() as session: + await session.execute( + text( + "INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) " + "VALUES (:id, :uid, :s3, :fn, :mime, :bytes, 'queued')" + ), + { + "id": document_id, + "uid": user_id, + "s3": s3_key, + "fn": "bad.pdf", + "mime": "application/pdf", + "bytes": 9, + }, + ) + await session.execute( + text( + "INSERT INTO jobs (document_id, correlation_id, queue, status, max_attempts) " + "VALUES (:did, :cid, 'extract', 'pending', 2)" + ), + {"did": document_id, "cid": correlation_id}, + ) + await session.commit() + + handler = ExtractHandler(session_factory=sess_factory) + from contract_check.core.analysis.extractor import ExtractionError + from contract_check.core.analysis.ocr import OCRError + + # The handler may raise ExtractionError or OCRError depending on local + # tesseract data; either way the terminal-failure path refunds the credit. + with pytest.raises((ExtractionError, OCRError)): + await handler.handle( + DocumentUploaded( + correlation_id=correlation_id, + document_id=document_id, + user_id=user_id, + s3_key=s3_key, + filename="bad.pdf", + mime="application/pdf", + ) + ) + + await handler.on_terminal_failure( + DocumentUploaded( + correlation_id=correlation_id, + document_id=document_id, + user_id=user_id, + s3_key=s3_key, + filename="bad.pdf", + mime="application/pdf", + ), + "extraction_failed", + "test terminal failure", + ) + + async with sess_factory() as session: + res = await session.execute( + text("SELECT status, refunded FROM documents WHERE id = :d"), + {"d": document_id}, + ) + row = res.first() + assert row is not None + status, refunded = row + assert status == "failed" + assert refunded is True + + credits = await session.execute( + text("SELECT credits_left FROM users WHERE id = :u"), + {"u": user_id}, + ) + assert credits.scalar_one() == initial_credits + 1 diff --git a/tests/integration/test_upload_pipeline.py b/tests/integration/test_upload_pipeline.py new file mode 100644 index 0000000..2bc3256 --- /dev/null +++ b/tests/integration/test_upload_pipeline.py @@ -0,0 +1,130 @@ +"""Upload pipeline integration test: POST /api/v1/documents → MinIO + RabbitMQ + credits. + +Verifies the api: + - accepts a multipart PDF upload with a service token and telegram_id, + - reserves a credit, + - stores the blob in MinIO under the expected key, + - publishes a `DocumentUploaded` message to the `extract.q` queue, + - returns 202 with document_id/correlation_id/credits_left. +""" + +from __future__ import annotations + +import json +import uuid +from pathlib import Path +from typing import TYPE_CHECKING + +import aio_pika +import httpx +import pytest +from tests.integration.conftest import user_token + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def pdf_bytes(tmp_path: Path) -> bytes: + # The real test requires pymupdf to create a minimal PDF. + import pymupdf + + doc = pymupdf.open() + page = doc.new_page() + long_text = ( + "Договор. Стороны обязуются выполнять условия. " + "Сторона А обязуется передать товар. " + "Сторона Б обязуется оплатить товар в течение десяти банковских дней. " + "Ответственность сторон ограничена суммой договора. " + "Споры подлежат рассмотрению в арбитражном суде города Москвы." + ) + page.insert_htmlbox( + page.rect, + f'

{long_text}

', + ) + path = tmp_path / "contract.pdf" + doc.save(str(path)) + doc.close() + return path.read_bytes() + + +async def test_upload_document_reserves_credit_and_enqueues( + client: httpx.AsyncClient, + infra: dict[str, str], + pdf_bytes: bytes, + db_session: AsyncSession, +) -> None: + from sqlalchemy import text + + telegram_id = 123456789 + await db_session.execute( + text( + "INSERT INTO users (telegram_id, credits_left) VALUES (:t, 5) " + "ON CONFLICT (telegram_id) DO UPDATE SET credits_left = 5" + ), + {"t": telegram_id}, + ) + await db_session.commit() + + token = await user_token(client, infra, telegram_id) + + response = await client.post( # type: ignore[misc] + "/api/v1/documents", + headers={"Authorization": f"Bearer {token}"}, + files={"file": ("contract.pdf", pdf_bytes, "application/pdf")}, + ) + assert response.status_code == 202 + body = response.json() + document_id = uuid.UUID(body["document_id"]) + correlation_id = uuid.UUID(body["correlation_id"]) + assert body["credits_left"] == 4 # 5 - 1 + + # Verify a DocumentUploaded message landed on extract.q. + # When worker-extract is running it consumes quickly; in that case we + # verify the pipeline completes by checking analyze.q instead. + import time + + connection = await aio_pika.connect_robust(infra["rabbitmq_url"]) + try: + channel = await connection.channel() + deadline = 10.0 + found = False + try: + queue = await channel.get_queue("extract.q", ensure=False) + while deadline > 0: + start = time.monotonic() + message = await queue.get(timeout=deadline) + await message.ack() + msg_body = json.loads(message.body.decode("utf-8")) + if msg_body["document_id"] == str(document_id): + assert msg_body["correlation_id"] == str(correlation_id) + assert msg_body["user_id"] # any valid UUID + assert msg_body["s3_key"].endswith(".pdf") + assert msg_body["mime"] == "application/pdf" + found = True + break + deadline -= time.monotonic() - start + except aio_pika.exceptions.QueueEmpty: + # Worker already consumed it; assert end-to-end completion. + analyze_queue = await channel.get_queue("analyze.q", ensure=False) + # Use a consumer iterator so we don't depend on quorum-queue + # basic_get behaviour returning stale/empty results under concurrency. + async with analyze_queue.iterator() as queue_iter: + async for message in queue_iter: + async with message.process(): + msg_body = json.loads(message.body.decode("utf-8")) + if msg_body["document_id"] == str(document_id): + assert msg_body["correlation_id"] == str(correlation_id) + assert msg_body["extracted_s3_key"].endswith(".txt") + assert msg_body["char_count"] > 0 + found = True + break + if found: + break + if time.monotonic() - start > deadline: + break + assert found, "expected pipeline message not found on extract.q or analyze.q" + finally: + await connection.close() diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..66b5207 --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,198 @@ +"""Unit tests for core.auth: JWT signing/verification and Telegram identity checks. + +These tests do not touch the database; they exercise only the crypto helpers. +""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import hmac +import uuid +from urllib.parse import urlencode + +import pytest + +from contract_check.core.auth import ( + AuthError, + TokenExpiredError, + TokenInvalidError, + create_access_token, + verify_access_token, + verify_bot_identity, + verify_telegram_miniapp_init_data, + verify_telegram_web_payload, +) +from contract_check.core.config import get_settings + +_BOT_TOKEN = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi" +_SECRET_KEY = hmac.new( + _BOT_TOKEN.encode("utf-8"), + b"WebAppData", + hashlib.sha256, +).digest() + + +def _make_web_payload(telegram_id: int, auth_date: int | None = None) -> dict[str, object]: + if auth_date is None: + auth_date = int(dt.datetime.now(tz=dt.UTC).timestamp()) + data = { + "id": telegram_id, + "first_name": "Test", + "auth_date": auth_date, + } + data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(data.items())) + data["hash"] = hmac.new( + _SECRET_KEY, + data_check_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return data + + +def _make_init_data(telegram_id: int, auth_date: int | None = None) -> str: + if auth_date is None: + auth_date = int(dt.datetime.now(tz=dt.UTC).timestamp()) + user_json = f'{{"id":{telegram_id},"first_name":"Test"}}' + params = {"user": user_json, "auth_date": str(auth_date), "chat_type": "private"} + data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(params.items())) + params["hash"] = hmac.new( + _SECRET_KEY, + data_check_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return urlencode(params) + + +@pytest.fixture(autouse=True) +def _clear_settings_cache(monkeypatch: pytest.MonkeyPatch) -> None: + get_settings.cache_clear() + monkeypatch.setenv("JWT_SECRET", "unit-test-secret-do-not-use-in-prod") + monkeypatch.setenv("JWT_ALGORITHM", "HS256") + monkeypatch.setenv("JWT_ACCESS_TTL_MINUTES", "1440") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +def test_create_and_verify_access_token() -> None: + user_id = uuid.uuid4() + telegram_id = 42 + token = create_access_token(user_id, telegram_id) + claims = verify_access_token(token) + assert claims.sub == user_id + assert claims.telegram_id == telegram_id + assert claims.type == "access" + + +def test_verify_token_rejects_tampered_signature() -> None: + token = create_access_token(uuid.uuid4(), 42) + # Flip a bit in the middle of the payload; this breaks the signature reliably. + tampered = token[:-10] + ("A" if token[-10] != "A" else "B") + token[-9:] + with pytest.raises(TokenInvalidError): + verify_access_token(tampered) + + +def test_verify_token_rejects_expired_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("JWT_ACCESS_TTL_MINUTES", "-1") + get_settings.cache_clear() + token = create_access_token(uuid.uuid4(), 42) + with pytest.raises(TokenExpiredError): + verify_access_token(token) + + +def test_verify_token_rejects_wrong_audience() -> None: + import jwt + + user_id = uuid.uuid4() + token = jwt.encode( + { + "sub": str(user_id), + "telegram_id": 42, + "type": "access", + "exp": int((dt.datetime.now(tz=dt.UTC) + dt.timedelta(hours=1)).timestamp()), + "iat": int(dt.datetime.now(tz=dt.UTC).timestamp()), + "aud": "wrong-audience", + }, + key=get_settings().jwt_secret, + algorithm="HS256", + ) + with pytest.raises(TokenInvalidError): + verify_access_token(token) + + +def test_verify_token_rejects_missing_telegram_id() -> None: + import jwt + + user_id = uuid.uuid4() + token = jwt.encode( + { + "sub": str(user_id), + "type": "access", + "exp": int((dt.datetime.now(tz=dt.UTC) + dt.timedelta(hours=1)).timestamp()), + "iat": int(dt.datetime.now(tz=dt.UTC).timestamp()), + "aud": "contract-check", + }, + key=get_settings().jwt_secret, + algorithm="HS256", + ) + with pytest.raises(TokenInvalidError): + verify_access_token(token) + + +def test_verify_telegram_web_payload_valid() -> None: + payload = _make_web_payload(12345) + identity = verify_telegram_web_payload(payload, _BOT_TOKEN) + assert identity.telegram_id == 12345 + + +def test_verify_telegram_web_payload_bad_hash() -> None: + payload = _make_web_payload(12345) + payload["hash"] = "0" * 64 + with pytest.raises(AuthError, match="signature mismatch"): + verify_telegram_web_payload(payload, _BOT_TOKEN) + + +def test_verify_telegram_web_payload_expired() -> None: + old_auth_date = int(dt.datetime.now(tz=dt.UTC).timestamp()) - 25 * 60 * 60 + payload = _make_web_payload(12345, auth_date=old_auth_date) + with pytest.raises(AuthError, match="expired"): + verify_telegram_web_payload(payload, _BOT_TOKEN) + + +def test_verify_telegram_web_payload_missing_bot_token() -> None: + with pytest.raises(AuthError, match="not configured"): + verify_telegram_web_payload(_make_web_payload(1), "") + + +def test_verify_telegram_miniapp_init_data_valid() -> None: + init_data = _make_init_data(67890) + identity = verify_telegram_miniapp_init_data(init_data, _BOT_TOKEN) + assert identity.telegram_id == 67890 + + +def test_verify_telegram_miniapp_init_data_bad_hash() -> None: + init_data = _make_init_data(67890) + init_data = init_data[:-64] + "0" * 64 + with pytest.raises(AuthError, match="signature mismatch"): + verify_telegram_miniapp_init_data(init_data, _BOT_TOKEN) + + +def test_verify_telegram_miniapp_init_data_missing_user() -> None: + # Sign an empty data-check-string; hash is valid, but there is no user param. + empty_hash = hmac.new(_SECRET_KEY, b"", hashlib.sha256).hexdigest() + bad = f"hash={empty_hash}" + with pytest.raises(AuthError, match="missing user"): + verify_telegram_miniapp_init_data(bad, _BOT_TOKEN) + + +def test_verify_bot_identity_valid() -> None: + identity = verify_bot_identity(111222) + assert identity.telegram_id == 111222 + + +def test_verify_bot_identity_invalid() -> None: + with pytest.raises(AuthError): + verify_bot_identity(-1) + with pytest.raises(AuthError): + verify_bot_identity(0) diff --git a/tests/unit/test_bot_boundary.py b/tests/unit/test_bot_boundary.py new file mode 100644 index 0000000..92788e4 --- /dev/null +++ b/tests/unit/test_bot_boundary.py @@ -0,0 +1,159 @@ +"""Hexagonal boundary enforcement for the bot adapter (docs/ARCHITECTURE.md §4, §17). + +The bot must not import any core state/infra module or third-party driver it +should not need: `core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`, +plus `sqlalchemy`, `asyncpg`, `minio`, `aio_pika`, `pymupdf`/`fitz`, +`pytesseract`, `PIL`. Allowed: `core.logging`, `core.config` (type aliases only), +aiogram, httpx, pydantic, structlog. + +A static AST scan is used (not an import probe) so the check still catches a +regression even though those libs are installed in the local dev env — the bot +Docker image does not ship them, so an accidental import would only explode in +production. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +BOT_PKG = Path(__file__).resolve().parents[2] / "src" / "contract_check" / "bot" +# Module names are rooted at `src/` so relative imports resolve to their full +# `contract_check.bot.*` / `contract_check.core.*` dotted form. +SRC_ROOT = BOT_PKG.parents[1] + +FORBIDDEN_PREFIXES = ( + "contract_check.core.db", + "contract_check.core.s3", + "contract_check.core.llm", + "contract_check.core.mq", + "contract_check.core.credits", + "sqlalchemy", + "asyncpg", + "alembic", + "minio", + "aio_pika", + "aio-pika", + "pymupdf", + "fitz", + "pytesseract", + "PIL", +) + +# `core.config` is allowed (type aliases) but the bot must never *instantiate* +# the infra `Settings` (which requires DB/MQ/S3 env vars). We assert the symbol +# is not referenced by name. +FORBIDDEN_NAMES = {"get_settings"} + + +def _bot_files() -> list[Path]: + return sorted(p for p in BOT_PKG.rglob("*.py") if p.is_file()) + + +def _module_and_pkg(path: Path, src_root: Path = SRC_ROOT) -> tuple[str, str]: + """Return (absolute module name, current package) for resolving relative imports.""" + parts = path.relative_to(src_root).with_suffix("").parts + if parts[-1] == "__init__": + module_parts = list(parts[:-1]) + current_pkg = ".".join(parts[:-1]) + else: + module_parts = list(parts) + current_pkg = ".".join(parts[:-1]) + return ".".join(module_parts), current_pkg + + +def _resolve_relative(current_pkg: str, level: int, module: str | None) -> str: + base_parts = current_pkg.split(".") if current_pkg else [] + # Drop (level - 1) trailing components to find the base package. + if level - 1 > 0: + base_parts = base_parts[: len(base_parts) - (level - 1)] + base = ".".join(base_parts) + return f"{base}.{module}" if module else base + + +def _imports_in(path: Path, src_root: Path = SRC_ROOT): + tree = ast.parse(path.read_text(encoding="utf-8")) + _, current_pkg = _module_and_pkg(path, src_root) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + yield alias.name, node.lineno + elif isinstance(node, ast.ImportFrom): + if node.level and node.level > 0: + target = _resolve_relative(current_pkg, node.level, node.module) + else: + target = node.module or "" + yield target, node.lineno + + +def _forbidden_in(path: Path, src_root: Path = SRC_ROOT) -> list[str]: + offenders = [] + for target, lineno in _imports_in(path, src_root): + for prefix in FORBIDDEN_PREFIXES: + if target == prefix or target.startswith(prefix + "."): + offenders.append(f"line {lineno}: imports {target!r}") + return offenders + + +def test_bot_package_exists() -> None: + assert BOT_PKG.is_dir(), f"bot package not found at {BOT_PKG}" + assert _bot_files(), "bot package has no .py files" + + +def test_resolver_catches_forbidden_relative_import(tmp_path: Path) -> None: + """The resolver must turn `from ..core.db import X` into `contract_check.core.db` + so the forbidden-prefix match actually fires. Regression for an earlier bug + where relative imports resolved without the `contract_check.` prefix.""" + pkg = tmp_path / "contract_check" / "bot" + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text("", encoding="utf-8") + bad = pkg / "leak.py" + bad.write_text("from ..core.db import User\n", encoding="utf-8") + + offenders = _forbidden_in(bad, src_root=tmp_path) + assert offenders, "expected forbidden import to be detected" + assert "contract_check.core.db" in offenders[0] + + +def test_resolver_allows_core_logging(tmp_path: Path) -> None: + pkg = tmp_path / "contract_check" / "bot" + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text("", encoding="utf-8") + good = pkg / "ok.py" + good.write_text("from ..core.logging import get_logger\n", encoding="utf-8") + + assert _forbidden_in(good, src_root=tmp_path) == [] + + +@pytest.mark.parametrize("path", _bot_files(), ids=lambda p: p.relative_to(BOT_PKG).as_posix()) +def test_no_forbidden_imports(path: Path) -> None: + offenders = _forbidden_in(path) + assert not offenders, f"{path.relative_to(BOT_PKG)} violates boundary:\n " + "\n ".join( + offenders + ) + + +@pytest.mark.parametrize("path", _bot_files(), ids=lambda p: p.relative_to(BOT_PKG).as_posix()) +def test_does_not_instantiate_infra_settings(path: Path) -> None: + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Name) and node.id in FORBIDDEN_NAMES: + pytest.fail( + f"{path.relative_to(BOT_PKG)} line {node.lineno}: " + f"references {node.id!r} (bot must use its own BotSettings)" + ) + + +def test_bot_imports_resolve() -> None: + """The bot package imports cleanly with only the lean bot deps installed in dev.""" + import importlib + + for mod in ( + "contract_check.bot", + "contract_check.bot.config", + "contract_check.bot.client", + "contract_check.bot.handlers", + ): + importlib.import_module(mod) diff --git a/tests/unit/test_bot_client.py b/tests/unit/test_bot_client.py new file mode 100644 index 0000000..468d020 --- /dev/null +++ b/tests/unit/test_bot_client.py @@ -0,0 +1,277 @@ +"""Bot ApiClient + delivery-helpers unit tests (respx-mocked, no network). + +Covers the api response contract the bot relies on: + - POST /api/v1/auth/telegram/bot -> {access_token, ...} + - GET /api/v1/me -> {telegram_id, credits_left} (Bearer user JWT) + - POST /api/v1/documents -> 202 {document_id, correlation_id, credits_left} + | 402 | 400 | other (Bearer user JWT) + - GET /api/v1/reports/{id} -> 202-ish {document_id, status, stage} + | 200 {..., markdown, filename} when done + | 404 (Bearer user JWT) +Plus the disclaimer guarantee and error-class mapping (docs/ARCHITECTURE.md §8, §17). +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from contract_check.bot.client import ( + ApiClient, + ApiError, + NoCreditsError, + UnsupportedFormatError, + ensure_disclaimer, +) +from contract_check.bot.config import BotSettings + +BASE = "http://api:8000" +_FAKE_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDAiLCJ0ZWxlZ3JhbV9pZCI6NDIsInR5cGUiOiJhY2Nlc3MiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MSwiYXVkIjoiY29udHJhY3QtY2hlY2sifQ.test-signature" + + +def _settings() -> BotSettings: + return BotSettings( + bot_token="123:abc", + api_url=f"{BASE}", + bot_service_token="secret-token", + ) + + +def _client() -> ApiClient: + c = ApiClient(_settings()) + c._client = httpx.AsyncClient( + base_url=BASE, + ) + return c + + +def _login(api: ApiClient, telegram_id: int) -> None: + """Seed a fake JWT into the bot cache so user-scoped calls succeed.""" + api._token_cache[telegram_id] = _FAKE_JWT + + +@respx.mock +async def test_login_exchanges_telegram_id_for_jwt() -> None: + route = respx.post(f"{BASE}/api/v1/auth/telegram/bot").mock( + return_value=httpx.Response( + 200, + json={ + "access_token": _FAKE_JWT, + "token_type": "bearer", + "expires_in": 86400, + "user_id": "00000000-0000-0000-0000-000000000000", + "telegram_id": 42, + }, + ) + ) + api = _client() + await api.login(42, "cid-login") + assert route.called + req = route.calls.last.request + assert req.headers["Authorization"] == "Bearer secret-token" + assert req.headers["X-Correlation-ID"] == "cid-login" + assert api._token_cache.get(42) == _FAKE_JWT + await api.aclose() + + +def test_ensure_disclaimer_appends_when_missing() -> None: + out = ensure_disclaimer("# Отчёт\nбез дисклеймера") + assert "Дисклеймер" in out + assert out.startswith("# Отчёт") + + +def test_ensure_disclaimer_idempotent_when_present() -> None: + md = "# Отчёт\n\n> ⚠️ **Дисклеймер.** Уже есть." + assert ensure_disclaimer(md) == md + + +@pytest.mark.parametrize( + "env_value,json_logs", + [("dev", False), ("prod", True), ("staging", True)], +) +def test_bot_settings_json_logs(env_value: str, json_logs: bool) -> None: + s = BotSettings( + bot_token="t", + api_url=BASE, + bot_service_token="x", + env=env_value, + ) + assert s.json_logs is json_logs + + +@respx.mock +async def test_get_credits_returns_balance() -> None: + respx.get(f"{BASE}/api/v1/me").mock( + return_value=httpx.Response(200, json={"telegram_id": 42, "credits_left": 7}) + ) + api = _client() + _login(api, 42) + credits = await api.get_credits(42, "cid-1") + assert credits == 7 + + request = respx.calls.last.request + assert request.headers["Authorization"] == f"Bearer {_FAKE_JWT}" + assert request.headers["X-Correlation-ID"] == "cid-1" + assert "telegram_id" not in request.url.params + await api.aclose() + + +@respx.mock +async def test_get_credits_raises_on_error() -> None: + respx.get(f"{BASE}/api/v1/me").mock(return_value=httpx.Response(500, json={"detail": "boom"})) + api = _client() + _login(api, 42) + with pytest.raises(ApiError) as exc: + await api.get_credits(42, "cid") + assert exc.value.status_code == 500 + assert "boom" in exc.value.detail + await api.aclose() + + +@respx.mock +async def test_upload_document_success() -> None: + route = respx.post(f"{BASE}/api/v1/documents").mock( + return_value=httpx.Response( + 202, + json={ + "document_id": "doc-1", + "correlation_id": "corr-1", + "credits_left": 4, + }, + ) + ) + api = _client() + _login(api, 42) + result = await api.upload_document(42, "cid-2", "contract.pdf", b"data", "application/pdf") + assert result.document_id == "doc-1" + assert result.correlation_id == "corr-1" + assert result.credits_left == 4 + assert route.called + + req = route.calls.last.request + assert "telegram_id" not in req.url.params + assert req.headers["X-Correlation-ID"] == "cid-2" + assert req.headers["Authorization"] == f"Bearer {_FAKE_JWT}" + await api.aclose() + + +@respx.mock +async def test_upload_document_no_credits_maps_to_402() -> None: + respx.post(f"{BASE}/api/v1/documents").mock( + return_value=httpx.Response(402, json={"detail": "no credits available"}) + ) + api = _client() + _login(api, 42) + with pytest.raises(NoCreditsError) as exc: + await api.upload_document(42, "cid", "c.pdf", b"d", "application/pdf") + assert exc.value.status_code == 402 + await api.aclose() + + +@respx.mock +async def test_upload_document_bad_format_maps_to_400() -> None: + respx.post(f"{BASE}/api/v1/documents").mock( + return_value=httpx.Response(400, json={"detail": "unsupported format '.exe'"}) + ) + api = _client() + _login(api, 42) + with pytest.raises(UnsupportedFormatError): + await api.upload_document(42, "cid", "c.exe", b"d", "application/octet-stream") + await api.aclose() + + +@respx.mock +async def test_upload_document_other_error_maps_to_apierror() -> None: + respx.post(f"{BASE}/api/v1/documents").mock( + return_value=httpx.Response(503, text="service unavailable") + ) + api = _client() + _login(api, 42) + with pytest.raises(ApiError) as exc: + await api.upload_document(42, "cid", "c.pdf", b"d", "application/pdf") + assert exc.value.status_code == 503 + await api.aclose() + + +@respx.mock +async def test_get_report_pending_returns_status_and_stage() -> None: + respx.get(f"{BASE}/api/v1/reports/doc-1").mock( + return_value=httpx.Response( + 200, + json={"document_id": "doc-1", "status": "extracting", "stage": "extract"}, + ) + ) + api = _client() + _login(api, 42) + report = await api.get_report(42, "cid", "doc-1") + assert report.status == "extracting" + assert report.stage == "extract" + assert report.markdown is None + req = respx.calls.last.request + assert "telegram_id" not in req.url.params + assert req.headers["X-Correlation-ID"] == "cid" + assert req.headers["Authorization"] == f"Bearer {_FAKE_JWT}" + await api.aclose() + + +@respx.mock +async def test_get_report_done_returns_markdown() -> None: + respx.get(f"{BASE}/api/v1/reports/doc-1").mock( + return_value=httpx.Response( + 200, + json={ + "document_id": "doc-1", + "status": "done", + "filename": "contract.pdf", + "markdown": "# Отчёт\n\n> ⚠️ **Дисклеймер.** готовый.", + "findings": [], + "model_used": "qwen2.5:14b", + "prompt_tokens": 100, + "eval_tokens": 200, + "latency_ms": 1234, + }, + ) + ) + api = _client() + _login(api, 42) + report = await api.get_report(42, "cid", "doc-1") + assert report.status == "done" + assert report.markdown is not None + assert report.filename == "contract.pdf" + await api.aclose() + + +@respx.mock +async def test_get_report_not_found_raises_apierror() -> None: + respx.get(f"{BASE}/api/v1/reports/doc-x").mock( + return_value=httpx.Response(404, json={"detail": "report not found"}) + ) + api = _client() + _login(api, 42) + with pytest.raises(ApiError) as exc: + await api.get_report(42, "cid", "doc-x") + assert exc.value.status_code == 404 + await api.aclose() + + +async def test_client_not_started_raises() -> None: + api = ApiClient(_settings()) + with pytest.raises(RuntimeError): + _ = api.client + + +@respx.mock +async def test_failed_report_payload_has_no_markdown() -> None: + respx.get(f"{BASE}/api/v1/reports/doc-1").mock( + return_value=httpx.Response( + 200, + json={"document_id": "doc-1", "status": "failed", "stage": "analyze"}, + ) + ) + api = _client() + _login(api, 42) + report = await api.get_report(42, "cid", "doc-1") + assert report.status == "failed" + assert report.markdown is None + await api.aclose() diff --git a/tests/unit/test_checklist_report.py b/tests/unit/test_checklist_report.py new file mode 100644 index 0000000..579e05f --- /dev/null +++ b/tests/unit/test_checklist_report.py @@ -0,0 +1,23 @@ +"""Checklist + report schema unit tests (ported from the stage-0 smoke test).""" + +from __future__ import annotations + +from contract_check.core.analysis.checklist import CHECKLIST, checklist_for_prompt +from contract_check.core.analysis.report_schema import ReportPayload + + +def test_checklist_has_10_items() -> None: + assert len(CHECKLIST) == 10 + assert all(c.id and c.title and c.description for c in CHECKLIST) + + +def test_checklist_prompt_is_numbered_with_ids() -> None: + rendered = checklist_for_prompt() + assert "[penalties]" in rendered + assert rendered.count("\n") == 9 + + +def test_report_payload_accepts_empty() -> None: + payload = ReportPayload() + assert payload.findings == [] + assert "findings" in ReportPayload.model_json_schema()["properties"] diff --git a/tests/unit/test_chunker.py b/tests/unit/test_chunker.py new file mode 100644 index 0000000..a3fc65a --- /dev/null +++ b/tests/unit/test_chunker.py @@ -0,0 +1,23 @@ +"""Chunker unit tests (ported from the prototype smoke test + extra cases).""" + +from __future__ import annotations + +import pytest + +from contract_check.core.analysis.chunker import chunk_text + + +def test_short_text_returns_single_chunk() -> None: + assert chunk_text("одна строка", max_chars=100) == ["одна строка"] + + +def test_respects_max_chars() -> None: + text = "\n".join(f"Абзац номер {i}." for i in range(500)) + chunks = chunk_text(text, max_chars=100) + assert chunks + assert all(len(c) <= 100 for c in chunks) + + +def test_nonpositive_max_chars_raises() -> None: + with pytest.raises(ValueError): + chunk_text("abc", max_chars=0) diff --git a/tests/unit/test_credits.py b/tests/unit/test_credits.py new file mode 100644 index 0000000..f2ae483 --- /dev/null +++ b/tests/unit/test_credits.py @@ -0,0 +1,35 @@ +"""Credits policy unit tests — pure decision logic (DB behavior is integration).""" + +from __future__ import annotations + +from contract_check.core.credits import NON_REFUNDABLE_INFRA_ONLY, should_refund + + +def test_policy_all_refunds_everything() -> None: + for failure in ( + "extraction_failed", + "ocr_failed", + "llm_quota", + "llm_invalid_output", + "llm_timeout", + "infra", + "unknown", + ): + assert should_refund(failure, "all") is True + + +def test_policy_infra_only_skips_extraction_failed() -> None: + assert should_refund("extraction_failed", "infra_only") is False + for failure in ( + "ocr_failed", + "llm_quota", + "llm_invalid_output", + "llm_timeout", + "infra", + "unknown", + ): + assert should_refund(failure, "infra_only") is True + + +def test_non_refundable_set_is_just_extraction_failed() -> None: + assert NON_REFUNDABLE_INFRA_ONLY == frozenset({"extraction_failed"}) diff --git a/tests/unit/test_extractor.py b/tests/unit/test_extractor.py new file mode 100644 index 0000000..8ca7a70 --- /dev/null +++ b/tests/unit/test_extractor.py @@ -0,0 +1,25 @@ +"""Extractor unit tests — error paths only (real extraction is integration).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from contract_check.core.analysis.extractor import SUPPORTED_SUFFIXES, ExtractionError, extract_text + + +def test_missing_file_raises(tmp_path: Path) -> None: + with pytest.raises(ExtractionError, match="не найден"): + extract_text(tmp_path / "nope.pdf") + + +def test_unsupported_suffix_raises(tmp_path: Path) -> None: + f = tmp_path / "contract.txt" + f.write_text("hello" * 50) + with pytest.raises(ExtractionError, match="Неподдерживаемый формат"): + extract_text(f) + + +def test_supported_suffixes_cover_pdf_docx() -> None: + assert {".pdf", ".docx"} <= SUPPORTED_SUFFIXES diff --git a/tests/unit/test_llm_ollama_cloud.py b/tests/unit/test_llm_ollama_cloud.py new file mode 100644 index 0000000..917ca71 --- /dev/null +++ b/tests/unit/test_llm_ollama_cloud.py @@ -0,0 +1,226 @@ +"""Ollama Cloud adapter unit tests (respx-mocked). + +Covers: 200 happy path, 429→fallback, invalid-JSON→repair→success, and the +provider returning a merged AnalysisResult. No network. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from contract_check.core.analysis.report_schema import ReportPayload +from contract_check.core.llm.ollama_cloud import ( + LLMConfigError, + LLMError, + LLMQuotaError, + LLMUnavailableError, + OllamaCloudProvider, +) + +HOST = "https://ollama.test" +URL = f"{HOST}/api/chat" + +VALID_PAYLOAD = ReportPayload( + findings=[ + { + "checklist_id": "penalties", + "severity": "high", + "quote": "Штраф 0,5% за каждый день просрочки", + "section_ref": "п. 6.3", + "risk": "Высокая неустойка", + "recommendation": "Ограничить cap", + } + ] +) + + +def _chat_body(content: str, model: str = "qwen2.5:14b") -> dict[str, object]: + return { + "model": model, + "message": {"content": content}, + "prompt_eval_count": 10, + "eval_count": 20, + } + + +def _provider() -> OllamaCloudProvider: + return OllamaCloudProvider( + host=HOST, + api_key="key", + model="qwen2.5:14b", + fallback_model="qwen2.5:7b", + max_concurrency=1, + chunk_size=10000, + ) + + +@pytest.mark.asyncio +async def test_happy_path_returns_findings() -> None: + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + mock.post(URL).mock( + return_value=httpx.Response(200, json=_chat_body(VALID_PAYLOAD.model_dump_json())) + ) + result = await provider.analyze("договор " * 200) + + assert len(result.findings) == 1 + assert result.findings[0].checklist_id == "penalties" + assert result.findings[0].severity == "high" + assert result.prompt_tokens == 10 + assert result.eval_tokens == 20 + assert result.fell_back is False + + +@pytest.mark.asyncio +async def test_429_falls_back_to_secondary_model() -> None: + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + route = mock.post(URL) + route.mock( + side_effect=[ + httpx.Response(429, json={"error": "quota"}), + httpx.Response( + 200, json=_chat_body(VALID_PAYLOAD.model_dump_json(), model="qwen2.5:7b") + ), + ] + ) + result = await provider.analyze("договор " * 200) + + assert result.fell_back is True + assert "qwen2.5:7b" in result.models_used + + +@pytest.mark.asyncio +async def test_invalid_json_repaired_on_second_attempt() -> None: + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + mock.post(URL).mock( + side_effect=[ + httpx.Response(200, json=_chat_body("not valid json {")), + httpx.Response(200, json=_chat_body(VALID_PAYLOAD.model_dump_json())), + ] + ) + result = await provider.analyze("договор " * 200) + + assert result.repaired is True + assert len(result.findings) == 1 + + +@pytest.mark.asyncio +async def test_model_aliases_validate_without_repair() -> None: + """Models often emit risk_type/description and omit recommendation. + + The schema accepts these aliases, so no repair loop should fire. + """ + aliased = json.dumps( + { + "findings": [ + { + "risk_type": "penalties", + "severity": "medium", + "description": "Высокая неустойка.", + "quote": "Штраф 0,5% за каждый день просрочки", + "section_ref": "п. 6.3", + } + ] + } + ) + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + route = mock.post(URL).mock(return_value=httpx.Response(200, json=_chat_body(aliased))) + result = await provider.analyze("договор " * 200) + assert route.call_count == 1 + assert result.repaired is False + assert result.findings[0].checklist_id == "penalties" + assert result.findings[0].risk == "Высокая неустойка." + assert result.findings[0].recommendation == "" + + +@pytest.mark.asyncio +async def test_429_with_no_fallback_raises_quota() -> None: + provider = OllamaCloudProvider( + host=HOST, api_key="key", model="qwen2.5:14b", fallback_model=None + ) + async with provider: + with respx.mock(base_url=HOST) as mock: + mock.post(URL).mock(return_value=httpx.Response(429, json={"error": "quota"})) + with pytest.raises(LLMQuotaError): + await provider.analyze("договор " * 200) + + +@pytest.mark.asyncio +async def test_invalid_json_after_repair_raises_llm_error() -> None: + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + mock.post(URL).mock(return_value=httpx.Response(200, json=_chat_body("still not json"))) + with pytest.raises(LLMError): + await provider.analyze("договор " * 200) + + +@pytest.mark.asyncio +async def test_short_text_single_chunk_one_request() -> None: + """Short text (< chunk_size) produces exactly one LLM call.""" + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + route = mock.post(URL).mock( + return_value=httpx.Response(200, json=_chat_body(VALID_PAYLOAD.model_dump_json())) + ) + await provider.analyze("короткий договор") + assert route.call_count == 1 + + +@pytest.mark.asyncio +async def test_multi_chunk_merges_findings() -> None: + """Two chunks → two calls → findings merged and deduped.""" + findings = json.loads(VALID_PAYLOAD.model_dump_json())["findings"] + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + mock.post(URL).mock( + return_value=httpx.Response( + 200, + json=_chat_body(json.dumps({"findings": findings + findings})), + ) + ) + result = await provider.analyze("договор " * 3000, checklist="x") + # dedupe collapses identical (checklist_id, quote) pairs → 1 finding + assert len(result.findings) == 1 + + +@pytest.mark.asyncio +async def test_404_endpoint_missing_raises_config_error() -> None: + """A 404 from Ollama is a terminal config error, not a retryable one.""" + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + route = mock.post(URL) + route.mock(return_value=httpx.Response(404, text="Not Found")) + with pytest.raises(LLMConfigError): + await provider.analyze("договор " * 200) + assert route.call_count == 1 + + +@pytest.mark.asyncio +async def test_connect_error_raises_config_error_immediately() -> None: + """A connection error is terminal: the configured host is wrong/down.""" + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + route = mock.post(URL) + route.mock(side_effect=httpx.ConnectError("Connection refused")) + with pytest.raises(LLMConfigError): + await provider.analyze("договор " * 200) + assert route.call_count == 1 + + +@pytest.mark.asyncio +async def test_503_retries_then_raises_unavailable() -> None: + """5xx errors retry and eventually surface as LLMUnavailableError.""" + async with _provider() as provider: + with respx.mock(base_url=HOST) as mock: + route = mock.post(URL) + route.mock(return_value=httpx.Response(503, text="Unavailable")) + with pytest.raises(LLMUnavailableError): + await provider.analyze("договор " * 200) + assert route.call_count == 3 diff --git a/tests/unit/test_messages.py b/tests/unit/test_messages.py new file mode 100644 index 0000000..1c05486 --- /dev/null +++ b/tests/unit/test_messages.py @@ -0,0 +1,66 @@ +"""RabbitMQ message schema unit tests.""" + +from __future__ import annotations + +import uuid + +import pytest + +from contract_check.core.mq.messages import DocumentExtracted, DocumentUploaded + + +def _ids() -> tuple[uuid.UUID, uuid.UUID, uuid.UUID]: + return uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + + +def test_document_uploaded_roundtrip() -> None: + cid, did, uid = _ids() + msg = DocumentUploaded( + correlation_id=cid, + document_id=did, + user_id=uid, + s3_key=f"users/{uid}/docs/{did}.pdf", + filename="contract.pdf", + mime="application/pdf", + ) + parsed = DocumentUploaded.model_validate_json(msg.model_dump_json()) + assert parsed == msg + assert parsed.attempt == 0 + + +def test_next_attempt_increments() -> None: + cid, did, uid = _ids() + msg = DocumentUploaded( + correlation_id=cid, document_id=did, user_id=uid, s3_key="k", filename="f", mime="m" + ) + assert msg.attempt == 0 + assert msg.next_attempt().attempt == 1 + assert msg.next_attempt().next_attempt().attempt == 2 + + +def test_negative_attempt_rejected() -> None: + cid, did, uid = _ids() + with pytest.raises(ValueError): + DocumentUploaded( + correlation_id=cid, + document_id=did, + user_id=uid, + s3_key="k", + filename="f", + mime="m", + attempt=-1, + ) + + +def test_document_extracted_fields() -> None: + cid, did, uid = _ids() + msg = DocumentExtracted( + correlation_id=cid, + document_id=did, + user_id=uid, + extracted_s3_key=f"users/{uid}/docs/{did}.txt", + char_count=4242, + ocr_used=True, + ) + assert msg.char_count == 4242 + assert msg.ocr_used is True diff --git a/tests/unit/test_rate_limit.py b/tests/unit/test_rate_limit.py new file mode 100644 index 0000000..31205dd --- /dev/null +++ b/tests/unit/test_rate_limit.py @@ -0,0 +1,54 @@ +"""Unit tests for the token-bucket rate limiter.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from contract_check.core.rate_limit import MemoryRateLimiter + + +@pytest.fixture +def limiter() -> MemoryRateLimiter: + return MemoryRateLimiter() + + +async def test_memory_rate_limiter_allows_first_requests(limiter: MemoryRateLimiter) -> None: + # With limit=3, the first 3 requests should be allowed immediately. + results = [await limiter.allow("key-a", 3) for _ in range(3)] + assert all(r.allowed for r in results) + assert all(r.retry_after_sec is None for r in results) + + +async def test_memory_rate_limiter_blocks_excess(limiter: MemoryRateLimiter) -> None: + # First 3 allowed, 4th blocked with a retry_after hint. + for _ in range(3): + assert (await limiter.allow("key-b", 3)).allowed + + blocked = await limiter.allow("key-b", 3) + assert not blocked.allowed + assert blocked.retry_after_sec is not None + assert 0 < blocked.retry_after_sec <= 1 + + +async def test_memory_rate_limiter_replenishes_after_time(limiter: MemoryRateLimiter) -> None: + # Burn the bucket dry. + for _ in range(3): + assert (await limiter.allow("key-c", 3)).allowed + assert not (await limiter.allow("key-c", 3)).allowed + + # Wait long enough for one token to refill at 3 tokens/second. + await asyncio.sleep(0.5) + assert (await limiter.allow("key-c", 3)).allowed + + +async def test_memory_rate_limiter_keys_are_isolated(limiter: MemoryRateLimiter) -> None: + assert (await limiter.allow("key-1", 1)).allowed + assert not (await limiter.allow("key-1", 1)).allowed + assert (await limiter.allow("key-2", 1)).allowed + + +async def test_memory_rate_limiter_zero_limit_is_allowed(limiter: MemoryRateLimiter) -> None: + # RedisRateLimiter treats non-positive limits as misconfigured and allows. + assert (await limiter.allow("key-z", 0)).allowed diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..92103e9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2667 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version < '3.14'", +] + +[[package]] +name = "aio-pika" +version = "10.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiormq" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/01f4ea7fe3490194420bb52e596b9619092ed13c5a230014b02075c3bd77/aio_pika-10.0.1.tar.gz", hash = "sha256:96ec3ef748ca7a25a9d2fa6e511c16c3ffcfa6b1f40ade79b8a5baabba682efd", size = 70882, upload-time = "2026-07-09T13:31:35.709Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/3f/329d0e52f994349ff7449c714c242ad65f14586b0e205ca632ac817fda72/aio_pika-10.0.1-py3-none-any.whl", hash = "sha256:12120a3cf8022d2a8bc5dc89e716512a38bf742c24c5562f54764af27eec7edd", size = 56332, upload-time = "2026-07-09T13:31:33.634Z" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiogram" +version = "3.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "certifi" }, + { name = "magic-filter" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/07/5978f99d7e799843a6a248c5418ef99a8a4aedc8e411b736739b0d93b78f/aiogram-3.30.0.tar.gz", hash = "sha256:04a5c43d0acedaf907ffa9a1b6c651cd5fde35b5bca82f521e57d77a57e63bc6", size = 1932144, upload-time = "2026-07-17T20:33:33.38Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f7/0a828d29b3a3a59f22c03690b34773fadd64f1e9c3dc34c63cdb6eef0b72/aiogram-3.30.0-py3-none-any.whl", hash = "sha256:98c22665271912d258178cec591f78ee93a085a3423dd3b2e11c2851d4552470", size = 843818, upload-time = "2026-07-17T20:33:31.236Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiormq" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pamqp" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/16/7e1c2bb887db6cbad191db9a1562e1cf5c0c61ad93f194ddc7baf5661f02/aiormq-7.0.0.tar.gz", hash = "sha256:f524121f1afbb875f50235b2748f81331e3be47542ee600e83c321c4e97ea168", size = 49231, upload-time = "2026-07-09T11:40:51.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/88/8da3627882f6bd75f780f87e46d0b58da99332c1b71d038db7a127a80648/aiormq-7.0.0-py3-none-any.whl", hash = "sha256:df49bb2282e5374a28507c4c43948e8c8e5321590f2998781c2d90a34e100789", size = 32152, upload-time = "2026-07-09T11:40:50.508Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "alembic" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/01/a48dab7827ac4421272399f7ed9a2ec17edd12c8bcde4417bd7b6821b71a/alembic-1.19.0.tar.gz", hash = "sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501", size = 2069906, upload-time = "2026-08-04T18:57:04.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/79/59ab6f1fe72eee229de91aa393225389a85df12a0fbbbfa264efcf6d7872/alembic-1.19.0-py3-none-any.whl", hash = "sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580", size = 265738, upload-time = "2026-08-04T18:57:06.219Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "asgi-lifespan" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/da/e7908b54e0f8043725a990bf625f2041ecf6bfe8eb7b19407f1c00b630f7/asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308", size = 15627, upload-time = "2023-03-28T17:35:49.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" }, +] + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "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 = "contract-check" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "structlog" }, +] + +[package.dev-dependencies] +analyze = [ + { name = "aio-pika" }, + { name = "alembic" }, + { name = "asyncpg" }, + { name = "minio" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-sdk" }, + { name = "prometheus-client" }, + { name = "sentry-sdk" }, + { name = "sqlalchemy" }, +] +api = [ + { name = "aio-pika" }, + { name = "alembic" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "minio" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-sdk" }, + { name = "prometheus-client" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "redis" }, + { name = "sentry-sdk" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] +bot = [ + { name = "aiogram" }, +] +db = [ + { name = "alembic" }, + { name = "asyncpg" }, + { name = "sqlalchemy" }, +] +dev = [ + { name = "aio-pika" }, + { name = "aiogram" }, + { name = "aiosqlite" }, + { name = "alembic" }, + { name = "anyio" }, + { name = "asgi-lifespan" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "minio" }, + { name = "mypy" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-sdk" }, + { name = "pillow" }, + { name = "prometheus-client" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "pymupdf" }, + { name = "pytesseract" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "python-docx" }, + { name = "python-multipart" }, + { name = "redis" }, + { name = "respx" }, + { name = "ruff" }, + { name = "sentry-sdk" }, + { name = "sqlalchemy" }, + { name = "testcontainers", extra = ["minio", "rabbitmq"] }, + { name = "uvicorn", extra = ["standard"] }, +] +extract = [ + { name = "aio-pika" }, + { name = "alembic" }, + { name = "asyncpg" }, + { name = "minio" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, + { name = "pillow" }, + { name = "prometheus-client" }, + { name = "pymupdf" }, + { name = "pytesseract" }, + { name = "sentry-sdk" }, + { name = "sqlalchemy" }, +] +mq = [ + { name = "aio-pika" }, +] +obs = [ + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, + { name = "prometheus-client" }, + { name = "sentry-sdk" }, +] +prototype = [ + { name = "pymupdf" }, + { name = "python-docx" }, +] +s3 = [ + { name = "minio" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", extras = ["http2"], specifier = ">=0.27" }, + { name = "pydantic", specifier = ">=2.7" }, + { name = "pydantic-settings", specifier = ">=2.3" }, + { name = "python-dotenv", specifier = ">=1.0" }, + { name = "structlog", specifier = ">=24.1" }, +] + +[package.metadata.requires-dev] +analyze = [ + { name = "aio-pika", specifier = ">=9.4" }, + { name = "alembic", specifier = ">=1.13" }, + { name = "asyncpg", specifier = ">=0.29" }, + { name = "minio", specifier = ">=7.2" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.24" }, + { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.45b0" }, + { name = "opentelemetry-sdk", specifier = ">=1.24" }, + { name = "prometheus-client", specifier = ">=0.20" }, + { name = "sentry-sdk", specifier = ">=2" }, + { name = "sqlalchemy", specifier = ">=2.0" }, +] +api = [ + { name = "aio-pika", specifier = ">=9.4" }, + { name = "alembic", specifier = ">=1.13" }, + { name = "asyncpg", specifier = ">=0.29" }, + { name = "fastapi", specifier = ">=0.110" }, + { name = "minio", specifier = ">=7.2" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.24" }, + { name = "opentelemetry-instrumentation-asgi", specifier = ">=0.45b0" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.45b0" }, + { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.45b0" }, + { name = "opentelemetry-sdk", specifier = ">=1.24" }, + { name = "prometheus-client", specifier = ">=0.20" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "redis", specifier = ">=5.0" }, + { name = "sentry-sdk", specifier = ">=2" }, + { name = "sqlalchemy", specifier = ">=2.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, +] +bot = [{ name = "aiogram", specifier = ">=3.4" }] +db = [ + { name = "alembic", specifier = ">=1.13" }, + { name = "asyncpg", specifier = ">=0.29" }, + { name = "sqlalchemy", specifier = ">=2.0" }, +] +dev = [ + { name = "aio-pika", specifier = ">=9.4" }, + { name = "aiogram", specifier = ">=3.4" }, + { name = "aiosqlite", specifier = ">=0.20" }, + { name = "alembic", specifier = ">=1.13" }, + { name = "anyio", specifier = ">=4" }, + { name = "asgi-lifespan", specifier = ">=2.1.0" }, + { name = "asyncpg", specifier = ">=0.29" }, + { name = "fastapi", specifier = ">=0.110" }, + { name = "minio", specifier = ">=7.2" }, + { name = "mypy", specifier = ">=1.10" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.24" }, + { name = "opentelemetry-instrumentation-asgi", specifier = ">=0.45b0" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.45b0" }, + { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.45b0" }, + { name = "opentelemetry-sdk", specifier = ">=1.24" }, + { name = "pillow", specifier = ">=10" }, + { name = "prometheus-client", specifier = ">=0.20" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8" }, + { name = "pymupdf", specifier = ">=1.24" }, + { name = "pytesseract", specifier = ">=0.3.10" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "python-docx", specifier = ">=1.1" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "redis", specifier = ">=5.0" }, + { name = "respx", specifier = ">=0.21" }, + { name = "ruff", specifier = ">=0.5" }, + { name = "sentry-sdk", specifier = ">=2" }, + { name = "sqlalchemy", specifier = ">=2.0" }, + { name = "testcontainers", extras = ["rabbitmq", "postgres", "minio"], specifier = ">=4" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, +] +extract = [ + { name = "aio-pika", specifier = ">=9.4" }, + { name = "alembic", specifier = ">=1.13" }, + { name = "asyncpg", specifier = ">=0.29" }, + { name = "minio", specifier = ">=7.2" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.24" }, + { name = "opentelemetry-sdk", specifier = ">=1.24" }, + { name = "pillow", specifier = ">=10" }, + { name = "prometheus-client", specifier = ">=0.20" }, + { name = "pymupdf", specifier = ">=1.24" }, + { name = "pytesseract", specifier = ">=0.3.10" }, + { name = "sentry-sdk", specifier = ">=2" }, + { name = "sqlalchemy", specifier = ">=2.0" }, +] +mq = [{ name = "aio-pika", specifier = ">=9.4" }] +obs = [ + { name = "opentelemetry-exporter-otlp", specifier = ">=1.24" }, + { name = "opentelemetry-sdk", specifier = ">=1.24" }, + { name = "prometheus-client", specifier = ">=0.20" }, + { name = "sentry-sdk", specifier = ">=2" }, +] +prototype = [ + { name = "pymupdf", specifier = ">=1.24" }, + { name = "python-docx", specifier = ">=1.1" }, +] +s3 = [{ name = "minio", specifier = ">=7.2" }] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, +] + +[[package]] +name = "docker" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +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/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[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 = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.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 = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, +] + +[[package]] +name = "magic-filter" +version = "1.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/08/da7c2cc7398cc0376e8da599d6330a437c01d3eace2f2365f300e0f3f758/magic_filter-1.0.12.tar.gz", hash = "sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9", size = 11071, upload-time = "2023-10-01T12:33:19.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/75/f620449f0056eff0ec7c1b1e088f71068eb4e47a46eb54f6c065c6ad7675/magic_filter-1.0.12-py3-none-any.whl", hash = "sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6", size = 11335, upload-time = "2023-10-01T12:33:17.711Z" }, +] + +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "minio" +version = "7.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi" }, + { name = "certifi" }, + { name = "pycryptodome" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/45/7af37fe54e5d3e66e7dcd7ba8b8aeee73f202bfac909cc94b8c4e428f9ac/opentelemetry_exporter_otlp-1.44.0.tar.gz", hash = "sha256:af1cde7c33ea8ed624bf04ac49a885730fe44c1f1ad698656e592c38f70ce106", size = 6090, upload-time = "2026-07-16T15:25:34.585Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c3/7b466a9463944e70b37b744072a0c1b88a425dade3fff0631adec66c9bcc/opentelemetry_exporter_otlp-1.44.0-py3-none-any.whl", hash = "sha256:4a498fa8d8fd8be9e8e2d175fe5524a3fe581ccffadd8509db86526a5fb97051", size = 6727, upload-time = "2026-07-16T15:25:14.445Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/91/3c58961cb0360cd60509064734f0be4275383c8681d73c580a40ca83ddce/opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b", size = 42689, upload-time = "2026-07-16T15:25:50.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137", size = 36717, upload-time = "2026-07-16T15:24:51.424Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/83/8e8e83b7ac285281687c7be2fd305213ccccbb8c0a2dd4fb45a8ccaf12c7/opentelemetry_instrumentation_asgi-0.65b0.tar.gz", hash = "sha256:892bca67c56522ffa85a8a83cf934d7b50b3be2132e45cbee705825f0a5ba426", size = 26140, upload-time = "2026-07-16T15:25:54.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/9c/376962840b619d2d55fe8ee2285f8c70971c090e5fff614516fc654a6f3a/opentelemetry_instrumentation_asgi-0.65b0-py3-none-any.whl", hash = "sha256:3a845a8ebd1c4ef0d8263401e6545f5b219b2feee612090d50f578a87e71fd65", size = 15903, upload-time = "2026-07-16T15:24:57.198Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/23/b057f8196d06efdc1b50e3ff11fbc499a7d96b35c87f217eb7885542f4ea/opentelemetry_instrumentation_fastapi-0.65b0.tar.gz", hash = "sha256:10a3a95486036230413a58fe4fdf4a83fa6bba46918407e527476994bd92bd97", size = 26236, upload-time = "2026-07-16T15:26:05.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b0/c9b0300d33349ecc3dfd2362516eaffc44877e90970e6a52178ff953fec3/opentelemetry_instrumentation_fastapi-0.65b0-py3-none-any.whl", hash = "sha256:cda2610a0ec1b22d19886f33e4d861e9f5dbb886aeaa3a1263b47aff82c36943", size = 13261, upload-time = "2026-07-16T15:25:12.429Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/03/a529140241addd4d0acc73bafbd6f74691651b92fc0ae9b4513cf80f07fa/opentelemetry_instrumentation_httpx-0.65b0.tar.gz", hash = "sha256:4627aa9c6bb99bf4462c8b565b0ef6aeb9ffad95c6c92868be1ef7895de112ee", size = 26309, upload-time = "2026-07-16T15:26:07.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/0f/c6144096b4914bbf44b43ba21c962e8f333ff045770b50a3e79ed8bd455f/opentelemetry_instrumentation_httpx-0.65b0-py3-none-any.whl", hash = "sha256:400f1b78afa4ee2332b5debe58e1ed1b317913d58812c952576be76660aeadb1", size = 17436, upload-time = "2026-07-16T15:25:15.772Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/a9/d7525a59fdd240e69b5af4a6338e78fafa1b4203394122cbd6701fb5f84a/opentelemetry_util_http-0.65b0.tar.gz", hash = "sha256:84f82d826978bba416ab453460ff6a7391cdc3534c93a786595e4068680016b7", size = 11243, upload-time = "2026-07-16T15:26:27.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/3f/ab8d29df207ce5f470a07fa96ebb48af4e95b7fab7e7635311b9a32f2fab/opentelemetry_util_http-0.65b0-py3-none-any.whl", hash = "sha256:7553b606f963097cb190536dc30556cce85090692e471a422fff30ca29b04348", size = 8245, upload-time = "2026-07-16T15:25:46.482Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pamqp" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/4c/33a0ddaaac7bc42f9a542dbaaee8b580ceca3f89bf5da7c498d1fa97ff9a/pamqp-4.0.1.tar.gz", hash = "sha256:9dd13b828e346622793981f14a5df817fce5de998c746209d6c0154eb8403970", size = 137192, upload-time = "2026-07-06T16:37:51.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/14/1dfc08b743ba995a38dee0ea09beb46a05c7fe8ac53d729095905f7bf11d/pamqp-4.0.1-py3-none-any.whl", hash = "sha256:a547f45128b06e42ce8d7a739b0cfcc40f2c724770622eaaff4a3f587b1cf7d0", size = 32773, upload-time = "2026-07-06T16:37:50.623Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pika" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/26/54e0b98a7f60b474cb0a6c05ecf048d4bc8c866e10ab1c82bc83865e7421/pika-1.4.4.tar.gz", hash = "sha256:8cfc8b33a5cb16e733bd60cffca9732c0d1d761ecd80a89f34ed7df2cd38d6d6", size = 154713, upload-time = "2026-08-06T21:33:39.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f3/921170b78779ac3f8b405cb28fce88acca1562ac114072cbf0a115e19bb9/pika-1.4.4-py3-none-any.whl", hash = "sha256:48de960c97a93b55db06b8be4c53eb977c9c8a2754c57cdae9097abcbd70ce04", size = 165275, upload-time = "2026-08-06T21:33:38.449Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[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 = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + +[[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/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" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[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 = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pymupdf" +version = "1.28.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/fb/b6761fa2d5266f2cdb24c3b91f4023070ab7848381417678e7a289a1d52a/pymupdf-1.28.2.tar.gz", hash = "sha256:5e0be7908a715aa20333caddd73f1d6f01e4cd0c26e869fa2dd0b7f344da2249", size = 87903557, upload-time = "2026-08-06T21:43:23.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/51/550c9a75c4ff3245cb4ecb7bb95cbe2ab7374230b8e2b7a1f7259444150b/pymupdf-1.28.2-cp310-abi3-macosx_10_15_x86_64.whl", hash = "sha256:5fc315b425ff1f7afdd1ea2f348205cb19b806767daae7ce4d64115799c2bae1", size = 24645079, upload-time = "2026-08-06T21:37:25.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/3591f781b417b382a8487a2356e927acfe858b1043bab0ec47f6805bb109/pymupdf-1.28.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7113846b35dbf0a033f088e4f4fb543dabeb4b0b12c112966a1ca1ee2d5eacae", size = 23875605, upload-time = "2026-08-06T21:37:40.369Z" }, + { url = "https://files.pythonhosted.org/packages/d2/86/4a68f080b71b46802178346af46486e1697508e760855ff5f3b218a6dff7/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3050a233dde1211efe89ada74e2add6238436434159f46097a1423aad2842545", size = 25095554, upload-time = "2026-08-06T21:37:58.485Z" }, + { url = "https://files.pythonhosted.org/packages/c7/06/dace3e27af26690cb20bead80dbac42941b0841eb689b8aabbd67dde16f0/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:397d6715c1f0df7548a92d0afd8ce370fc48fa47aeefac16be2bc04a16a8227f", size = 25762500, upload-time = "2026-08-06T21:38:17.438Z" }, + { url = "https://files.pythonhosted.org/packages/e5/61/4146dfa1d8172a1ce8d59f0eed94896ddefb8deb2274534d0522fbb8abf5/pymupdf-1.28.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f89fb2d86d07d643a269f17a093105057e20c79c1d06c103b53600067b6d2b01", size = 25986309, upload-time = "2026-08-06T21:38:35.472Z" }, + { url = "https://files.pythonhosted.org/packages/52/60/1fb6e64676f7500ebe89054b9e5bbbe14d3101c92d5f1a40ac9a35227673/pymupdf-1.28.2-cp310-abi3-win32.whl", hash = "sha256:530ef543a3885b3b81cb72a854e7c5a625a9233201221132bb6c31698c6a2bdb", size = 18525353, upload-time = "2026-08-06T21:38:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/4a/61/d563bbccba262f9dd6d2d35ccb72593648184d886188efb12d9ce8f34dd6/pymupdf-1.28.2-cp310-abi3-win_amd64.whl", hash = "sha256:ebd244918798502d7b4504c90410d1711a4d7675a32584ca30f1bab419ecbffe", size = 19826532, upload-time = "2026-08-06T21:39:00.213Z" }, + { url = "https://files.pythonhosted.org/packages/e2/93/08f404a1f0155fe24137cf2d3aabd3e2b4b08c62053ed89c60f2611be3e9/pymupdf-1.28.2-cp310-abi3-win_arm64.whl", hash = "sha256:ffe91a24edc75c80da2a4b62f50fc0f54632d34fc8fe4cbc48e5c7ff07cf8fb4", size = 19759252, upload-time = "2026-08-06T21:39:12.937Z" }, + { url = "https://files.pythonhosted.org/packages/58/8c/d897dcd32a25b58186c968b15ce4324ca029e9d96460de12325314e390be/pymupdf-1.28.2-cp313-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2e1b574c0fd2cb238021033fd3c0f9c4388816638df064e4bfb56d9d81736dc8", size = 18399403, upload-time = "2026-08-06T21:39:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f1/de34a1c53fe2bf8c6e71db84b0ced782d408970c9810d2b456a2ae96814c/pymupdf-1.28.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:fd481ed48bef56305c41fb7e05a055c03345c899c7b101dad086258b438f8168", size = 25802333, upload-time = "2026-08-06T21:39:41.426Z" }, +] + +[[package]] +name = "pytesseract" +version = "0.3.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a6/7d679b83c285974a7cb94d739b461fa7e7a9b17a3abfd7bf6cbc5c2394b0/pytesseract-0.3.13.tar.gz", hash = "sha256:4bf5f880c99406f52a3cfc2633e42d9dc67615e69d8a509d74867d3baddb5db9", size = 17689, upload-time = "2024-08-16T02:33:56.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl", hash = "sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34", size = 14705, upload-time = "2024-08-16T02:36:10.09Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + +[[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 = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.66.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/6f/d59cad0889d15fde85254cf58e701484de3f3f0406003b3197746910b19b/sentry_sdk-2.66.1.tar.gz", hash = "sha256:f882fb08710c5f8bfc603aafa3e901b384009a19cc3f76a572b863392ee81cdc", size = 940543, upload-time = "2026-07-22T12:26:54.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/d3/726bd88f0eece09ddf431bea4c9191c18e7a8d070b854eb0014d447712ee/sentry_sdk-2.66.1-py3-none-any.whl", hash = "sha256:86002793161d9a95ef04bdd8d442e9bfece5d989b755f05d6360215094a7aff6", size = 505555, upload-time = "2026-07-22T12:26:52.71Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "starlette" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/3c/76d2fd1f1357ed0f0108d8a5aa233dcf16e2946a8559c84912fe08e01ac7/starlette-1.4.1.tar.gz", hash = "sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540", size = 2709041, upload-time = "2026-08-05T15:17:26.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/0a/67e95f21498de41433babf7b1db0eeab449eb58872dfb831b27747a70fd0/starlette-1.4.1-py3-none-any.whl", hash = "sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19", size = 74019, upload-time = "2026-08-05T15:17:24.357Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +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 = "testcontainers" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/13/2cc466bddf26d0085f30a2b2bd56b7f8708b54a54db833eec97c5c69129b/testcontainers-4.15.0.tar.gz", hash = "sha256:085cde086337632e19002719460b7b80bbab2bdd51bb3ea04f77d0de96504706", size = 95340, upload-time = "2026-07-24T23:08:01.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl", hash = "sha256:8796c14e76604031ad39cf0ed3b8e9806283a1fbf5270965c2b1c594caa31b74", size = 160771, upload-time = "2026-07-24T23:08:00.13Z" }, +] + +[package.optional-dependencies] +minio = [ + { name = "minio" }, +] +rabbitmq = [ + { name = "pika" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "17.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" }, + { url = "https://files.pythonhosted.org/packages/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" }, + { url = "https://files.pythonhosted.org/packages/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" }, + { url = "https://files.pythonhosted.org/packages/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" }, + { url = "https://files.pythonhosted.org/packages/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" }, + { url = "https://files.pythonhosted.org/packages/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" }, + { url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" }, + { url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" }, + { url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" }, + { url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" }, + { url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" }, + { url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" }, + { url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" }, + { url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" }, + { url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" }, + { url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" }, + { url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" }, + { url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" }, +] + +[[package]] +name = "wrapt" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", hash = "sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107", size = 131509, upload-time = "2026-07-28T06:06:14.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f", size = 81960, upload-time = "2026-07-28T06:04:49.622Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f", size = 82435, upload-time = "2026-07-28T06:04:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5", size = 170350, upload-time = "2026-07-28T06:04:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0", size = 170022, upload-time = "2026-07-28T06:04:53.599Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609", size = 161043, upload-time = "2026-07-28T06:04:54.936Z" }, + { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8", size = 168576, upload-time = "2026-07-28T06:04:56.261Z" }, + { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae", size = 159140, upload-time = "2026-07-28T06:04:57.754Z" }, + { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3", size = 169263, upload-time = "2026-07-28T06:04:59.161Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", hash = "sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f", size = 78241, upload-time = "2026-07-28T06:05:00.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838", size = 81113, upload-time = "2026-07-28T06:05:01.839Z" }, + { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579", size = 80182, upload-time = "2026-07-28T06:05:03.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944", size = 83921, upload-time = "2026-07-28T06:05:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360", size = 84412, upload-time = "2026-07-28T06:05:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614", size = 207168, upload-time = "2026-07-28T06:05:07.256Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a", size = 214351, upload-time = "2026-07-28T06:05:08.945Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687", size = 199020, upload-time = "2026-07-28T06:05:10.418Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570", size = 209969, upload-time = "2026-07-28T06:05:11.983Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41", size = 196324, upload-time = "2026-07-28T06:05:13.557Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4", size = 202610, upload-time = "2026-07-28T06:05:15.048Z" }, + { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", hash = "sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3", size = 79178, upload-time = "2026-07-28T06:05:16.469Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98", size = 82634, upload-time = "2026-07-28T06:05:18.026Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6", size = 81387, upload-time = "2026-07-28T06:05:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc", size = 81978, upload-time = "2026-07-28T06:05:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1", size = 82518, upload-time = "2026-07-28T06:05:22.562Z" }, + { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945", size = 170187, upload-time = "2026-07-28T06:05:23.904Z" }, + { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5", size = 169288, upload-time = "2026-07-28T06:05:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3", size = 160932, upload-time = "2026-07-28T06:05:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07", size = 169017, upload-time = "2026-07-28T06:05:28.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f", size = 159065, upload-time = "2026-07-28T06:05:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23", size = 168821, upload-time = "2026-07-28T06:05:31.649Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", hash = "sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b", size = 78700, upload-time = "2026-07-28T06:05:33.391Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d", size = 81422, upload-time = "2026-07-28T06:05:34.774Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab", size = 80639, upload-time = "2026-07-28T06:05:36.228Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84", size = 84030, upload-time = "2026-07-28T06:05:37.714Z" }, + { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7", size = 84419, upload-time = "2026-07-28T06:05:39.131Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2", size = 207171, upload-time = "2026-07-28T06:05:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c", size = 214329, upload-time = "2026-07-28T06:05:42.287Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295", size = 199079, upload-time = "2026-07-28T06:05:43.958Z" }, + { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd", size = 209992, upload-time = "2026-07-28T06:05:45.677Z" }, + { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df", size = 196334, upload-time = "2026-07-28T06:05:47.608Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109", size = 202644, upload-time = "2026-07-28T06:05:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501", size = 79685, upload-time = "2026-07-28T06:05:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5", size = 83104, upload-time = "2026-07-28T06:05:52.405Z" }, + { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51", size = 81774, upload-time = "2026-07-28T06:05:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", hash = "sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2", size = 61866, upload-time = "2026-07-28T06:06:12.9Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +]