From 2fe54ffd86f7076f6dde1a87472fba806619a464 Mon Sep 17 00:00:00 2001 From: febux Date: Mon, 24 Aug 2026 00:45:39 +0300 Subject: [PATCH] Phase 2 - Raw SQL migration, pytest-cov coverage gate, CI branch triggers + coverage, Dead tooling & config drift --- .github/workflows/ci.yml | 66 +- .pre-commit-config.yaml | 2 +- Makefile | 5 +- alembic.ini | 3 +- docs/ARCHITECTURE.md | 38 +- pyproject.toml | 22 +- src/contract_check/api/__main__.py | 16 +- src/contract_check/api/admin/auth.py | 21 +- src/contract_check/api/admin/router.py | 26 +- src/contract_check/api/admin/users.py | 186 +-- src/contract_check/api/app.py | 16 +- src/contract_check/api/deps.py | 283 +---- src/contract_check/api/routes/auth.py | 1109 ----------------- .../api/routes/auth/__init__.py | 13 + .../api/routes/auth/magic_links.py | 153 +++ .../api/routes/auth/passkeys.py | 260 ++++ .../api/routes/auth/password.py | 227 ++++ src/contract_check/api/routes/auth/support.py | 121 ++ .../api/routes/auth/telegram.py | 117 ++ src/contract_check/api/routes/b2b.py | 273 ++-- src/contract_check/api/routes/documents.py | 7 +- src/contract_check/api/routes/health.py | 1 + src/contract_check/api/routes/me.py | 64 +- src/contract_check/api/routes/reports.py | 43 +- src/contract_check/api/schemas/__init__.py | 108 ++ src/contract_check/api/schemas/auth.py | 250 ++++ src/contract_check/api/schemas/b2b.py | 59 + src/contract_check/api/schemas/common.py | 51 + src/contract_check/api/schemas/documents.py | 11 + src/contract_check/api/schemas/me.py | 24 + src/contract_check/api/schemas/reports.py | 12 + src/contract_check/api/services.py | 77 +- src/contract_check/bot/handlers.py | 3 +- src/contract_check/core/analysis/extractor.py | 3 +- src/contract_check/core/api_keys.py | 9 +- src/contract_check/core/config.py | 11 +- src/contract_check/core/credits.py | 72 +- src/contract_check/core/db/enums.py | 23 +- src/contract_check/core/db/models.py | 122 +- .../core/db/repositories/__init__.py | 32 + .../core/db/repositories/api_keys.py | 220 ++++ .../core/db/repositories/credits.py | 97 ++ .../core/db/repositories/documents.py | 226 ++++ .../core/db/repositories/jobs.py | 175 +++ .../core/db/repositories/passkeys.py | 45 + .../core/db/repositories/prescreen_results.py | 98 ++ .../core/db/repositories/reports.py | 144 +++ .../core/db/repositories/service_tokens.py | 44 + .../core/db/repositories/users.py | 398 ++++++ .../core/extraction/__init__.py | 3 +- src/contract_check/core/extraction/factory.py | 15 +- src/contract_check/core/extraction/formats.py | 21 + src/contract_check/core/llm/errors.py | 34 + src/contract_check/core/tokens.py | 7 - src/contract_check/worker_analyze/handler.py | 116 +- src/contract_check/worker_extract/handler.py | 100 +- .../worker_prescreen/handler.py | 422 +++---- src/contract_check/worker_prescreen/router.py | 5 +- tests/conftest.py | 4 +- tests/integration/test_credits_db.py | 127 +- tests/unit/conftest.py | 41 + tests/unit/test_analyze_classify.py | 56 + tests/unit/test_db_models_sync.py | 94 ++ tests/unit/test_db_session.py | 127 ++ tests/unit/test_passkeys.py | 4 +- tests/unit/test_repositories.py | 233 ++++ uv.lock | 383 ++---- 67 files changed, 4509 insertions(+), 2669 deletions(-) delete mode 100644 src/contract_check/api/routes/auth.py create mode 100644 src/contract_check/api/routes/auth/__init__.py create mode 100644 src/contract_check/api/routes/auth/magic_links.py create mode 100644 src/contract_check/api/routes/auth/passkeys.py create mode 100644 src/contract_check/api/routes/auth/password.py create mode 100644 src/contract_check/api/routes/auth/support.py create mode 100644 src/contract_check/api/routes/auth/telegram.py create mode 100644 src/contract_check/api/schemas/__init__.py create mode 100644 src/contract_check/api/schemas/auth.py create mode 100644 src/contract_check/api/schemas/b2b.py create mode 100644 src/contract_check/api/schemas/common.py create mode 100644 src/contract_check/api/schemas/documents.py create mode 100644 src/contract_check/api/schemas/me.py create mode 100644 src/contract_check/api/schemas/reports.py create mode 100644 src/contract_check/core/db/repositories/__init__.py create mode 100644 src/contract_check/core/db/repositories/api_keys.py create mode 100644 src/contract_check/core/db/repositories/credits.py create mode 100644 src/contract_check/core/db/repositories/documents.py create mode 100644 src/contract_check/core/db/repositories/jobs.py create mode 100644 src/contract_check/core/db/repositories/passkeys.py create mode 100644 src/contract_check/core/db/repositories/prescreen_results.py create mode 100644 src/contract_check/core/db/repositories/reports.py create mode 100644 src/contract_check/core/db/repositories/service_tokens.py create mode 100644 src/contract_check/core/db/repositories/users.py create mode 100644 src/contract_check/core/extraction/formats.py create mode 100644 src/contract_check/core/llm/errors.py create mode 100644 tests/unit/conftest.py create mode 100644 tests/unit/test_analyze_classify.py create mode 100644 tests/unit/test_db_models_sync.py create mode 100644 tests/unit/test_db_session.py create mode 100644 tests/unit/test_repositories.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2c1a1e..0e38c90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, master] pull_request: - branches: [main] + branches: [main, master] concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -56,39 +56,37 @@ jobs: - name: Sync dev dependencies run: uv sync --group dev --frozen - - name: Run unit tests - run: uv run pytest -m "not integration" + - name: Run unit tests with coverage + run: uv run pytest --cov=src/contract_check --cov-branch --cov-report=term-missing --cov-fail-under=59 -m "not integration" tests/unit - # Integration tests are temporarily disabled in CI while the Docker Compose - # infrastructure startup issue is investigated. They can still be run locally - # with `docker compose up -d` followed by `uv run pytest -m integration`. - # test-integration: - # name: Integration tests - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - # - name: Setup uv - # uses: astral-sh/setup-uv@v8.3.2 - # with: - # enable-cache: true - # cache-dependency-glob: uv.lock - # - # - name: Install Python - # run: uv python install - # - # - name: Sync dev dependencies - # run: uv sync --group dev --frozen - # - # - name: Start infrastructure (postgres/redis/rabbitmq/minio) - # run: docker compose up -d --wait - # - # - name: Run integration tests - # run: uv run pytest -m integration - # - # - name: Teardown infrastructure - # if: always() - # run: docker compose down -v + # Integration tests. + test-integration: + name: Integration tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v8.3.2 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Install Python + run: uv python install + + - name: Sync dev dependencies + run: uv sync --group dev --frozen + + - name: Start infrastructure (postgres/redis/rabbitmq/minio) + run: docker compose up -d --wait + + - name: Run integration tests + run: uv run pytest -m integration + + - name: Teardown infrastructure + if: always() + run: docker compose down -v # Build and push service images on pushes to main. Set these repository secrets: # REGISTRY e.g. ghcr.io (or docker.io, your-private-registry.io) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86840c5..ad955ce 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ # Local hooks run through `uv run` so they use the pinned tool versions -# from uv.lock (ruff/mypy in the `dev` group). Install with: uv run pre-commit install +# from uv.lock (ruff/isort/ty in the `dev` group). Install with: uv run pre-commit install minimum_pre_commit_version: "4.0.0" diff --git a/Makefile b/Makefile index 7f3f481..a78e48f 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,9 @@ test: ## Run all tests (unit + integration) test-unit: ## Run unit tests only (fast) uv run pytest -m "not integration" +test-cov: ## Run unit tests with coverage report (enforces threshold) + uv run pytest --cov=src/contract_check --cov-branch --cov-report=term-missing --cov-report=html --cov-fail-under=59 -m "not integration" tests/unit + test-integration: ## Run integration tests (needs docker infra) uv run pytest -m integration @@ -206,7 +209,7 @@ shell-bot: ## Open shell inside bot container clean: ## Remove containers, volumes, caches docker compose --profile services down -v docker compose down -v - rm -rf .mypy_cache .pytest_cache .ruff_cache + rm -rf .pytest_cache .ruff_cache uv cache clean # ───────────────────────────────────────────────────────────────────────────── diff --git a/alembic.ini b/alembic.ini index 5cbcc5d..e872d36 100644 --- a/alembic.ini +++ b/alembic.ini @@ -1,5 +1,6 @@ # 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. +# (see migrations/env.py), so the [alembic] sqlalchemy.url here is a dev-only +# fallback for local `alembic` invocations without DATABASE_URL set. [alembic] script_location = migrations prepend_sys_path = src diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4bbf31f..97fd902 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -191,9 +191,9 @@ DealDocumentScreening/ │ │ ├── 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) +│ │ │ ├── models.py (SQLAlchemy 2 decl: User, PasskeyCredential, Document, Report, PrescreenResult, Job, ServiceToken, ApiKey, ApiKeyRequest, Invoice) │ │ │ ├── session.py (async_sessionmaker, engine) -│ │ │ └── enums.py (DocStatus, JobStatus, FailureClass — as plain str constants) +│ │ │ └── enums.py (DocStatus, JobStatus, QueueName, RoutingDecision, FailureClass — as plain str constants) │ │ ├── mq/ │ │ │ ├── __init__.py │ │ │ ├── topology.py (exchange/queue/rk constants + declare_all()) @@ -522,12 +522,40 @@ before api/worker start. Alternatively `core/s3/minio_storage.py` does --- -## 7. Postgres schema (Alembic migrations) +## 7. Postgres schema & data access (Alembic migrations + repositories) Six core tables plus additive migrations. `status`/`queue`/`adapter`/`role` columns are `TEXT + CHECK` (not Postgres enums) so migrations are additive — matches the convention noted in `IMPLEMENTATION_PLAN.md` §1.2. +### 7.1 Data-access layer + +All production SQL lives behind the repository package: + +``` +src/contract_check/core/db/repositories/ +├── api_keys.py (ApiKeyRepository) +├── credits.py (CreditsRepository — thin facade) +├── documents.py (DocumentRepository) +├── jobs.py (JobRepository) +├── passkeys.py (PasskeyRepository) +├── prescreen_results.py (PrescreenResultRepository) +├── reports.py (ReportRepository) +├── service_tokens.py (ServiceTokenRepository) +└── users.py (UserRepository) +``` + +Rules: +- A repository receives an `AsyncSession`; it **never** commits. +- Callers own transactions and commits. +- Raw `text()` is allowed only inside repositories or migrations. +- `api/deps.py` keeps lightweight dependency helpers but no direct SQL; it calls + `UserRepository`, `ApiKeyRepository`, etc. +- `core/credits.py` remains the public facade for credit operations; it now + delegates to `CreditsRepository`. + +### 7.2 Migrations + Migrations (hand-written, async `env.py`): - `0001` initial schema: users/documents/reports/jobs/service_tokens/invoices - `0002` `api_keys` + `api_key_requests` (B2B) @@ -616,7 +644,7 @@ CREATE TABLE prescreen_results ( error_message TEXT, retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0) ); -CREATE INDEX prescreen_results_document_idx ON prescreen_results (document_id); +CREATE INDEX ix_prescreen_results_document_id ON prescreen_results (document_id); CREATE INDEX prescreen_results_routing_idx ON prescreen_results (routing_decision); CREATE INDEX prescreen_results_confidence_idx ON prescreen_results (confidence_score); @@ -1443,7 +1471,7 @@ Each step is a verifiable unit. Do not start step N+1 until N is green 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, + db/models (all 10 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). diff --git a/pyproject.toml b/pyproject.toml index c284bd3..5fc342b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,14 +130,13 @@ dev = [ "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", "pre-commit>=4.6.2", "isort>=5.13", "ty>=0.0.72", + "pytest-cov>=7.1.0", ] [tool.ruff] @@ -169,3 +168,22 @@ pythonpath = ["."] markers = [ "integration: slow tests needing real Postgres/RabbitMQ/MinIO (deselect with '-m \"not integration\"')", ] + +[tool.coverage.run] +source = ["src/contract_check"] +branch = true +omit = [ + "*/migrations/*", + "*/prototype/*", + "*/__main__.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] +fail_under = 59 +show_missing = true +skip_covered = false diff --git a/src/contract_check/api/__main__.py b/src/contract_check/api/__main__.py index 7f24926..468fc2d 100644 --- a/src/contract_check/api/__main__.py +++ b/src/contract_check/api/__main__.py @@ -14,6 +14,7 @@ import sys import uvicorn from ..core.config import get_settings +from ..core.db.repositories import ServiceTokenRepository from ..core.db.session import create_session_factory from ..core.tokens import assert_adapter, generate_token, hash_token from .app import create_app @@ -24,19 +25,8 @@ async def _seed_token(name: str, adapter: str, token: str | None = None) -> None 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}, - ) + repo = ServiceTokenRepository(session) + await repo.upsert(name=name, token_hash=hash_token(raw), adapter=adapter) await session.commit() print(f"Token '{name}' ({adapter}) ready. Bearer:") print(raw) diff --git a/src/contract_check/api/admin/auth.py b/src/contract_check/api/admin/auth.py index 782803d..5199528 100644 --- a/src/contract_check/api/admin/auth.py +++ b/src/contract_check/api/admin/auth.py @@ -17,11 +17,11 @@ from dataclasses import dataclass from uuid import UUID from fastapi import Depends, HTTPException, Request, status -from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from ...core.auth import AuthError, verify_access_token from ...core.config import get_settings +from ...core.db.repositories import UserRepository from ..deps import AsyncSessionDep # HttpOnly cookie carrying the admin access JWT. @@ -76,22 +76,17 @@ async def resolve_admin(request: Request, session: AsyncSession) -> AdminUser | except AuthError: return None - result = await session.execute( - text("SELECT id, telegram_id, email, role, is_active FROM users WHERE id = :u"), - {"u": claims.sub}, - ) - row = result.first() - if row is None: + user = await UserRepository(session).get_by_id(claims.sub) + if user is None: return None - user_id, telegram_id, email, role, is_active = row - if not is_active or role != get_settings().admin_required_role: + if not user.is_active or user.role != get_settings().admin_required_role: return None return AdminUser( - user_id=user_id, - telegram_id=int(telegram_id or 0), - email=email, - role=role, + user_id=user.id, + telegram_id=int(user.telegram_id or 0), + email=user.email, + role=user.role, ) diff --git a/src/contract_check/api/admin/router.py b/src/contract_check/api/admin/router.py index e0fd513..a05ce3b 100644 --- a/src/contract_check/api/admin/router.py +++ b/src/contract_check/api/admin/router.py @@ -11,10 +11,10 @@ from typing import Annotated from fastapi import APIRouter, Form, Request, status from fastapi.responses import HTMLResponse, RedirectResponse -from sqlalchemy import text from ...core.auth import create_access_token from ...core.config import get_settings +from ...core.db.repositories import UserRepository from ...core.logging import get_logger from ...core.security.passwords import verify_password from ..deps import AsyncSessionDep @@ -75,34 +75,26 @@ async def login( ) -> RedirectResponse: """Verify email/password + admin role, set the cookie, redirect to users.""" email_norm = email.lower().strip() - result = await session.execute( - text( - "SELECT id, telegram_id, email, password_hash, role, is_active " - "FROM users WHERE email = :e" - ), - {"e": email_norm}, - ) - row = result.first() + user = await UserRepository(session).get_by_email(email_norm) invalid = RedirectResponse( url="/admin/login?reason=invalid", status_code=status.HTTP_303_SEE_OTHER ) - if row is None: + if user is None: return invalid - user_id, telegram_id, _email, password_hash, role, is_active = row - if not password_hash or not verify_password(password, password_hash): + if not user.password_hash or not verify_password(password, user.password_hash): return invalid - if not is_active: + if not user.is_active: return RedirectResponse( url="/admin/login?reason=disabled", status_code=status.HTTP_303_SEE_OTHER ) - if role != get_settings().admin_required_role: - log.warning("admin_login_forbidden", user_id=str(user_id), role=role) + if user.role != get_settings().admin_required_role: + log.warning("admin_login_forbidden", user_id=str(user.id), role=user.role) return RedirectResponse( url="/admin/login?reason=forbidden", status_code=status.HTTP_303_SEE_OTHER ) - token = create_access_token(user_id, int(telegram_id or 0)) + token = create_access_token(user.id, int(user.telegram_id or 0)) response = RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER) response.set_cookie( key=ADMIN_COOKIE, @@ -112,7 +104,7 @@ async def login( samesite="lax", secure=get_settings().env == "prod", ) - log.info("admin_logged_in", user_id=str(user_id)) + log.info("admin_logged_in", user_id=str(user.id)) return response diff --git a/src/contract_check/api/admin/users.py b/src/contract_check/api/admin/users.py index d4b4a47..d93dd7e 100644 --- a/src/contract_check/api/admin/users.py +++ b/src/contract_check/api/admin/users.py @@ -15,11 +15,11 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse, RedirectResponse -from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from ...core.config import get_settings from ...core.db.enums import USER_ROLE_USER, USER_ROLES +from ...core.db.repositories import DocumentRepository, UserRepository from ...core.logging import get_logger from ...core.security.passwords import hash_password from ..deps import AsyncSessionDep @@ -36,54 +36,30 @@ router = APIRouter( PAGE_SIZE = 25 -_USER_COLUMNS = ( - "id, telegram_id, email, role, is_active, credits_left, created_at, telegram_verified" -) + +def _user_to_dict(user: Any) -> dict[str, Any]: + return { + "id": user.id, + "telegram_id": user.telegram_id, + "email": user.email, + "role": user.role, + "is_active": bool(user.is_active), + "credits_left": int(user.credits_left), + "created_at": user.created_at, + "telegram_verified": bool(user.telegram_verified), + } async def _fetch_user(session: AsyncSession, user_id: uuid.UUID) -> dict[str, Any] | None: - result = await session.execute( - text(f"SELECT {_USER_COLUMNS} FROM users WHERE id = :u"), - {"u": user_id}, - ) - row = result.first() - if row is None: + user = await UserRepository(session).get_by_id(user_id) + if user is None: return None - return _user_row_to_dict(row) + return _user_to_dict(user) async def _fetch_user_stats(session: AsyncSession, user_id: uuid.UUID) -> dict[str, int]: """Aggregate counts shown on the detail page.""" - result = await session.execute( - text( - "SELECT count(*), " - " count(*) FILTER (WHERE status = 'done'), " - " count(*) FILTER (WHERE status = 'failed') " - "FROM documents WHERE user_id = :u" - ), - {"u": user_id}, - ) - row = result.first() - if row is None: - return {"docs_total": 0, "docs_done": 0, "docs_failed": 0} - return { - "docs_total": int(row[0] or 0), - "docs_done": int(row[1] or 0), - "docs_failed": int(row[2] or 0), - } - - -def _user_row_to_dict(row: Any) -> dict[str, Any]: - return { - "id": row[0], - "telegram_id": row[1], - "email": row[2], - "role": row[3], - "is_active": bool(row[4]), - "credits_left": int(row[5]), - "created_at": row[6], - "telegram_verified": bool(row[7]), - } + return await DocumentRepository(session).stats_for_user(user_id) @router.get("", response_class=HTMLResponse, include_in_schema=False) @@ -97,44 +73,27 @@ async def list_users( page = max(page, 1) offset = (page - 1) * PAGE_SIZE term = q.strip() - filter_sql = "" - filter_params: dict[str, Any] = {} # Optional admin filters via query string (no URL params in form; keep simple). only_telegram = request.query_params.get("only_telegram") == "1" unverified = request.query_params.get("unverified") == "1" - if only_telegram: - filter_sql += " AND telegram_id IS NOT NULL" - if unverified: - filter_sql += " AND telegram_verified = FALSE" - - base_where = "WHERE (email ILIKE :q OR telegram_id::text ILIKE :q)" if term else "WHERE TRUE" - base_where += filter_sql - like = f"%{term}%" - filter_params = {"q": like} if term else {} - - rows = ( - await session.execute( - text( - f"SELECT {_USER_COLUMNS} FROM users " - f"{base_where} " - "ORDER BY created_at DESC LIMIT :lim OFFSET :off" - ), - {**filter_params, "lim": PAGE_SIZE, "off": offset}, - ) - ).all() - total = int( - ( - await session.execute( - text(f"SELECT count(*) FROM users {base_where}"), - filter_params, - ) - ).scalar_one() + repo = UserRepository(session) + users_orm = await repo.list_users( + search=term or None, + has_telegram=only_telegram or None, + telegram_verified=False if unverified else None, + limit=PAGE_SIZE, + offset=offset, + ) + total = await repo.count_users( + search=term or None, + has_telegram=only_telegram or None, + telegram_verified=False if unverified else None, ) pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) - users = [_user_row_to_dict(r) for r in rows] + users = [_user_to_dict(u) for u in users_orm] return templates.TemplateResponse( request, "users_list.html", @@ -236,10 +195,8 @@ async def create_user( tg, ) - dup = ( - await session.execute(text("SELECT 1 FROM users WHERE email = :e"), {"e": email_norm}) - ).first() - if dup is not None: + repo = UserRepository(session) + if await repo.email_exists(email_norm): return _render_new_form( request, "Пользователь с таким email уже существует.", @@ -248,44 +205,32 @@ async def create_user( credits_left, tg, ) - if tg_id is not None: - dup_tg = ( - await session.execute(text("SELECT 1 FROM users WHERE telegram_id = :t"), {"t": tg_id}) - ).first() - if dup_tg is not None: - return _render_new_form( - request, - "Этот Telegram ID уже привязан к другому пользователю.", - email_norm, - role, - credits_left, - tg, - ) + if tg_id is not None and await repo.telegram_id_exists(tg_id): + return _render_new_form( + request, + "Этот Telegram ID уже привязан к другому пользователю.", + email_norm, + role, + credits_left, + tg, + ) # ── insert ── hashed = hash_password(password) - result = await session.execute( - text( - "INSERT INTO users " - "(email, password_hash, role, credits_left, telegram_id, is_active) " - "VALUES (:e, :p, :r, :c, :t, :a) " - f"RETURNING {_USER_COLUMNS}" - ), - { - "e": email_norm, - "p": hashed, - "r": role, - "c": credits, - "t": tg_id, - "a": is_active == "on", - }, + new_user = await repo.create_email_user( + email=email_norm, + name=None, + password_hash=hashed, + credits_left=credits, + role=role, + telegram_id=tg_id, + is_active=is_active == "on", ) - row = result.first() - assert row is not None # RETURNING always yields the inserted row await session.commit() - new_id = row[0] - log.info("admin_user_created", user_id=str(new_id), email=email_norm, role=role) - return RedirectResponse(url=f"/admin/users/{new_id}", status_code=status.HTTP_303_SEE_OTHER) + log.info("admin_user_created", user_id=str(new_user.id), email=email_norm, role=role) + return RedirectResponse( + url=f"/admin/users/{new_user.id}", status_code=status.HTTP_303_SEE_OTHER + ) @router.get("/{user_id}", response_class=HTMLResponse, include_in_schema=False) @@ -323,11 +268,10 @@ async def update_user( """Update role / active flag / credits. Returns the refreshed user card.""" if role not in USER_ROLES: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid role") - credits = max(0, int(credits_left)) - await session.execute( - text("UPDATE users SET role = :r, is_active = :a, credits_left = :c WHERE id = :u"), - {"r": role, "a": is_active == "on", "c": credits, "u": user_id}, - ) + repo = UserRepository(session) + await repo.set_role(user_id, role) + await repo.set_active(user_id, is_active == "on") + await repo.adjust_credits(user_id, max(0, int(credits_left))) await session.commit() user = await _fetch_user(session, user_id) @@ -349,10 +293,8 @@ async def toggle_active( session: AsyncSessionDep, _: Annotated[None, HtmxGuard], ) -> HTMLResponse: - await session.execute( - text("UPDATE users SET is_active = NOT is_active WHERE id = :u"), - {"u": user_id}, - ) + repo = UserRepository(session) + await repo.toggle_active(user_id) await session.commit() user = await _fetch_user(session, user_id) assert user is not None @@ -375,10 +317,7 @@ async def verify_telegram( _: Annotated[None, HtmxGuard], ) -> HTMLResponse: """Mark the user's Telegram identity as verified (admin review).""" - await session.execute( - text("UPDATE users SET telegram_verified = TRUE WHERE id = :u"), - {"u": user_id}, - ) + await UserRepository(session).verify_telegram(user_id) await session.commit() user = await _fetch_user(session, user_id) assert user is not None @@ -401,10 +340,7 @@ async def adjust_credits( ) -> HTMLResponse: """Bump credits by ``delta`` (clamped at 0). HTMX button target.""" if delta != 0: - await session.execute( - text("UPDATE users SET credits_left = greatest(0, credits_left + :d) WHERE id = :u"), - {"d": int(delta), "u": user_id}, - ) + await UserRepository(session).adjust_credits(user_id, int(delta)) await session.commit() user = await _fetch_user(session, user_id) assert user is not None diff --git a/src/contract_check/api/app.py b/src/contract_check/api/app.py index 5152b32..21a22d8 100644 --- a/src/contract_check/api/app.py +++ b/src/contract_check/api/app.py @@ -14,6 +14,7 @@ from fastapi import FastAPI from fastapi.responses import RedirectResponse from ..core.config import get_settings +from ..core.db.repositories import UserRepository from ..core.db.session import dispose_engine from ..core.llm import port as llm_port # noqa: F401 — package loaded from ..core.logging import bind_context, configure_logging, get_logger @@ -140,8 +141,6 @@ async def _ensure_default_admin(settings: Any, redis_client: Any) -> None: Uses a one-off DB session because the FastAPI dependency chain is not available during lifespan setup. Requires web auth (Redis) to be available. """ - from sqlalchemy import text - from ..core.db.session import create_session_factory from ..core.security.passwords import hash_password @@ -152,21 +151,16 @@ async def _ensure_default_admin(settings: Any, redis_client: Any) -> None: factory = create_session_factory() async with factory() as session: - existing = await session.execute( - text("SELECT id FROM users WHERE email = :e"), - {"e": settings.admin_default_email.lower().strip()}, - ) - if existing.first() is None: + repo = UserRepository(session) + existing = await repo.get_by_email(settings.admin_default_email.lower().strip()) + if existing is None: user = await create_email_user( session, email=settings.admin_default_email.lower().strip(), name="Administrator", password_hash=hash_password(settings.admin_default_password), ) - await session.execute( - text("UPDATE users SET role = :r WHERE id = :u"), - {"r": settings.admin_required_role, "u": user.id}, - ) + await repo.set_role(user.id, settings.admin_required_role) await session.commit() logger.info( "default_admin_created", diff --git a/src/contract_check/api/deps.py b/src/contract_check/api/deps.py index f721c29..92e8f3b 100644 --- a/src/contract_check/api/deps.py +++ b/src/contract_check/api/deps.py @@ -2,14 +2,12 @@ from __future__ import annotations -import datetime as dt -import json from collections.abc import AsyncIterator from typing import Annotated, Any from uuid import UUID from fastapi import Depends, Header, HTTPException, Request -from sqlalchemy import select, text +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ..core.api_keys import hash_api_key @@ -17,6 +15,13 @@ from ..core.auth import AuthError, TokenExpiredError, TokenInvalidError, verify_ from ..core.auth_refresh import RefreshTokenStore from ..core.config import get_settings from ..core.db.models import PasskeyCredential, User +from ..core.db.repositories import ( + ApiKeyRepository, + DocumentRepository, + PasskeyRepository, + UserRepository, +) +from ..core.db.repositories.service_tokens import ServiceTokenRepository from ..core.db.session import get_session from ..core.logging import get_logger from ..core.mq.publisher import Publisher @@ -118,18 +123,12 @@ async def require_service_token( 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: + repo = ServiceTokenRepository(session) + token_id = await repo.get_id_by_hash(token_hash) + if token_id 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 repo.bump_last_used(token_id) await session.commit() @@ -148,72 +147,29 @@ async def get_or_create_user_for_telegram( On creation stores the profile snapshot, verification flag, and binding time. On existing row optionally refreshes the profile snapshot. """ - result = await session.execute( - text( - "SELECT id, telegram_id, email, password_hash, is_active, " - "created_at, credits_left, telegram_verified " - "FROM users WHERE telegram_id = :t" - ), - {"t": telegram_id}, - ) - row = result.first() - if row: - # Refresh profile snapshot on every /start so admin sees current data. + repo = UserRepository(session) + user = await repo.get_by_telegram_id(telegram_id) + if user is not None: if profile: - await session.execute( - text( - "UPDATE users SET telegram_profile_json = :p, telegram_verified = :v " - "WHERE id = :u" - ), - {"p": json.dumps(profile, ensure_ascii=False), "v": verified, "u": row[0]}, - ) + await repo.update_telegram_profile(user.id, profile=profile, verified=verified) await session.commit() - return User( - id=row[0], - telegram_id=row[1], - email=row[2], - password_hash=row[3], - is_active=row[4], - created_at=row[5], - credits_left=row[6], - telegram_verified=verified if profile else row[7], - ) + user.telegram_verified = verified + return user - bound_at = dt.datetime.now(tz=dt.UTC) - profile_json = json.dumps(profile, ensure_ascii=False) if profile else None - insert = await session.execute( - text( - "INSERT INTO users (telegram_id, credits_left, telegram_profile_json, " - "telegram_verified, telegram_bound_at) " - "VALUES (:t, 0, :p, :v, :b) " - "RETURNING id, telegram_id, created_at, credits_left, is_active, " - "telegram_verified" - ), - {"t": telegram_id, "p": profile_json, "v": verified, "b": bound_at}, - ) - 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], - is_active=new[4], - telegram_verified=new[5], - ) + return await repo.create_telegram_user(telegram_id, profile=profile, verified=verified) 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: + user = await UserRepository(session).get_by_id(user_id) + if user is None: return None - return User(id=row[0], telegram_id=row[1], created_at=row[2], credits_left=row[3]) + return User( + id=user.id, + telegram_id=user.telegram_id, + created_at=user.created_at, + credits_left=user.credits_left, + ) async def bind_telegram_to_user( @@ -225,64 +181,31 @@ async def bind_telegram_to_user( Raises HTTPException 409 if the telegram_id is already bound to another user. """ - dup = await session.execute( - text("SELECT id FROM users WHERE telegram_id = :t AND id != :u"), - {"t": telegram_id, "u": user_id}, - ) - if dup.first() is not None: + repo = UserRepository(session) + if await repo.telegram_id_exists_for_other_user(telegram_id, user_id): raise HTTPException(status_code=409, detail="Telegram id already bound to another account") - await session.execute( - text("UPDATE users SET telegram_id = :t, telegram_bound_at = now() WHERE id = :u"), - {"t": telegram_id, "u": user_id}, - ) + await repo.bind_telegram(user_id, telegram_id) await session.commit() async def set_user_password(session: AsyncSession, user_id: UUID, password: str) -> None: """Set/rotate a web password for a user (Telegram -> web UI access).""" hashed = hash_password(password) - await session.execute( - text("UPDATE users SET password_hash = :p WHERE id = :u"), - {"p": hashed, "u": user_id}, - ) + await UserRepository(session).set_password(user_id, hashed) await session.commit() async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None: """Fetch a user by email (case-sensitive — normalize upstream). Returns None if not found.""" - result = await session.execute( - text( - "SELECT id, telegram_id, email, name, password_hash, is_active, created_at, credits_left " - "FROM users WHERE email = :e" - ), - {"e": email}, - ) - row = result.first() - if row is None: - return None - return User( - id=row[0], - telegram_id=row[1], - email=row[2], - name=row[3], - password_hash=row[4], - is_active=row[5], - created_at=row[6], - credits_left=row[7], - ) + return await UserRepository(session).get_by_email(email) async def fetch_passkey_credentials_for_user( session: AsyncSession, user_id: UUID ) -> list[PasskeyCredential]: """All passkey credentials of a user, newest first.""" - result = await session.execute( - select(PasskeyCredential) - .where(PasskeyCredential.user_id == user_id) - .order_by(PasskeyCredential.created_at.desc()) - ) - return list(result.scalars().all()) + return await PasskeyRepository(session).list_for_user(user_id) async def fetch_passkey_by_credential_id( @@ -297,52 +220,15 @@ async def fetch_passkey_by_credential_id( async def fetch_user_by_id_full(session: AsyncSession, user_id: UUID) -> User | None: """Fetch a user by UUID including web-auth columns.""" - result = await session.execute( - text( - "SELECT id, telegram_id, email, name, password_hash, is_active, 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], - email=row[2], - name=row[3], - password_hash=row[4], - is_active=row[5], - created_at=row[6], - credits_left=row[7], - ) + return await UserRepository(session).get_by_id(user_id) async def create_email_user( session: AsyncSession, *, email: str, name: str | None, password_hash: str ) -> User: """Insert a new email/password user with 0 credits and return it.""" - result = await session.execute( - text( - "INSERT INTO users (email, name, password_hash, credits_left) " - "VALUES (:e, :n, :p, 0) " - "RETURNING id, telegram_id, email, name, password_hash, is_active, created_at, credits_left" - ), - {"e": email, "n": name, "p": password_hash}, - ) - row = result.first() - assert row is not None - await session.commit() - return User( - id=row[0], - telegram_id=row[1], - email=row[2], - name=row[3], - password_hash=row[4], - is_active=row[5], - created_at=row[6], - credits_left=row[7], + return await UserRepository(session).create_email_user( + email=email, name=name, password_hash=password_hash ) @@ -364,18 +250,6 @@ class ApiKeyAuth: 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, @@ -390,31 +264,24 @@ async def require_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: + repo = ApiKeyRepository(session) + key = await repo.get_by_hash(key_hash) + if key 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: + if key.revoked: raise HTTPException(status_code=401, detail="Revoked API key") - await _maybe_reset_monthly_quota(session, api_key_id) + await repo.maybe_reset_monthly_quota(key.id) # Check monthly quota (if configured) after potential reset. + monthly_quota, monthly_used = await repo.get_monthly_quota_state(key.id) 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) + limit = int(key.rate_limit_rps or get_settings().b2b_default_rate_limit_rps) + rl_result = await rate_limiter.allow(f"rate_limit:{key.id}", limit) if not rl_result.allowed: retry_after = max(1, int(rl_result.retry_after_sec or 1)) raise HTTPException( @@ -423,13 +290,10 @@ async def require_api_key( 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 repo.bump_last_used(key.id) await session.commit() - return ApiKeyAuth(api_key_id=api_key_id, user_id=user_id, rate_limit_rps=limit) + return ApiKeyAuth(api_key_id=key.id, user_id=key.user_id, rate_limit_rps=limit) ApiKeyAuthDep = Annotated[ApiKeyAuth, Depends(require_api_key)] @@ -437,34 +301,23 @@ 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: +) -> dict[str, Any] | 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: + status = await DocumentRepository(session).get_with_report_by_id_for_user(document_id, user_id) + if status 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], + "id": status.id, + "status": status.status, + "stage": status.stage, + "filename": status.filename, + "created_at": status.created_at, + "markdown": status.markdown, + "content_json": status.content_json, + "model_used": status.model_used, + "prompt_tokens": status.prompt_tokens, + "eval_tokens": status.eval_tokens, + "latency_ms": status.latency_ms, } @@ -499,11 +352,7 @@ async def require_current_user( # 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: + if not await UserRepository(session).exists(claims.sub): raise HTTPException(status_code=401, detail="User not found") return CurrentUser(user_id=claims.sub, telegram_id=claims.telegram_id) @@ -513,11 +362,5 @@ CurrentUserDep = Annotated[CurrentUser, Depends(require_current_user)] 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]) + credits = await UserRepository(session).get_credits(user_id) + return credits if credits is not None else 0 diff --git a/src/contract_check/api/routes/auth.py b/src/contract_check/api/routes/auth.py deleted file mode 100644 index 043f913..0000000 --- a/src/contract_check/api/routes/auth.py +++ /dev/null @@ -1,1109 +0,0 @@ -"""Authentication endpoints: Telegram identity sources + webUI auth methods. - -Three Telegram identity sources converge on the same access 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 - -WebUI (email/password) endpoints issue a JWT pair (access + refresh): - - POST /auth/register — email + password -> user + pair - - POST /auth/login — email + password -> pair - - POST /auth/logout — revoke refresh - - GET /auth/me — current user (Bearer access JWT) - - POST /auth/forgot-password — store reset-token hash, enqueue notification - - POST /auth/reset-password — verify token, set new password - -Passwordless webUI auth (single access token, no refresh): - - POST /auth/passkeys/register/start|finish — add a passkey (Bearer) - - POST /auth/passkeys/authenticate/start|finish — passkey login - - GET /auth/passkeys — list passkeys (Bearer) - - DEL /auth/passkeys/{id} — remove a passkey (Bearer) - - POST /auth/magic-link/request — email one-time login link - - POST /auth/magic-link/verify — exchange link token for JWT - -Refresh tokens carry a `jti` tracked in Redis so logout is enforceable. -Password reset and magic-link tokens are stored as SHA-256 hashes with an -expiry in the users row; the actual delivery link is built by the notify -worker. WebAuthn challenges live in Redis (single-use, short TTL) — see -core/passkeys.py. Protected user endpoints receive the access JWT via -`Authorization: Bearer ` and use `deps.CurrentUser`. -""" - -from __future__ import annotations - -import datetime as dt -import hashlib -import secrets -import uuid -from typing import Annotated, Any - -from fastapi import APIRouter, Header, HTTPException, status -from pydantic import BaseModel, EmailStr, Field -from webauthn import verify_authentication_response, verify_registration_response -from webauthn.helpers import base64url_to_bytes, bytes_to_base64url -from webauthn.helpers.exceptions import ( - InvalidAuthenticationResponse, - InvalidRegistrationResponse, -) - -from ...core.auth import ( - AuthError, - create_access_token, - create_refresh_token, - verify_access_token, - verify_bot_identity, - verify_refresh_token, - verify_telegram_miniapp_init_data, - verify_telegram_web_payload, -) -from ...core.config import get_settings -from ...core.db.models import PasskeyCredential, User -from ...core.logging import get_logger -from ...core.mq.messages import NotificationMessage -from ...core.passkeys import ( - build_authentication_options, - build_registration_options, -) -from ...core.security.passwords import hash_password, verify_password -from ..deps import ( - AsyncSessionDep, - AuthDep, - CurrentUserDep, - NotificationPublisherDep, - PasskeyChallengeStoreDep, - RefreshStoreDep, - create_email_user, - fetch_passkey_by_credential_id, - fetch_passkey_credentials_for_user, - fetch_user_by_email, - fetch_user_by_id_full, - get_or_create_user_for_telegram, -) - -log = get_logger(__name__) -router = APIRouter(tags=["auth"]) - - -class TelegramProfile(BaseModel): - username: str | None = None - first_name: str | None = None - last_name: str | None = None - language_code: str | None = None - - -class TelegramBotAuthRequest(BaseModel): - telegram_id: int = Field(..., gt=0, description="Verified Telegram user id from aiogram") - profile: TelegramProfile | None = None - - -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 - - -class WebUserPublic(BaseModel): - """User profile subset safe to return to the webUI.""" - - id: str - email: str | None = None - name: str | None = None - telegram_id: int | None = None - credits_left: int - is_active: bool - created_at: dt.datetime - - -class MeResponse(TokenIntrospectResponse): - """Extends the introspection shape with web-user fields (additive).""" - - email: str | None = None - name: str | None = None - credits_left: int = 0 - is_active: bool = True - created_at: dt.datetime | None = None - - -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. - - Stores/updates the Telegram profile snapshot and sets telegram_verified - based on whether the profile looks legitimate. - """ - identity = verify_bot_identity(body.telegram_id) - profile = body.profile - verified = _telegram_profile_looks_verified(profile) - user = await get_or_create_user_for_telegram( - session, - identity.telegram_id, - profile=profile.model_dump(exclude_none=True) if profile else None, - verified=verified, - ) - if not user.is_active: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled") - return _issue_token(user) - - -def _telegram_profile_looks_verified(profile: TelegramProfile | None) -> bool: - if profile is None: - return False - return bool(profile.username or profile.first_name or profile.last_name) - - -@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", response_model=MeResponse, status_code=status.HTTP_200_OK) -async def me( - session: AsyncSessionDep, - authorization: Annotated[str | None, Header()] = None, -) -> MeResponse: - """Current user — verifies the Bearer JWT and returns the user's profile. - - Backward-compatible with the previous introspect shape (sub/telegram_id/ - type/exp) and adds email/credits_left/is_active/created_at. - """ - claims = _require_access_claims(authorization) - user = await fetch_user_by_id_full(session, claims.sub) - if user is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") - - return MeResponse( - sub=str(user.id), - telegram_id=user.telegram_id or 0, - type=claims.type, - exp=claims.exp or 0, - email=user.email, - name=user.name, - credits_left=user.credits_left, - is_active=bool(user.is_active), - created_at=user.created_at, - ) - - -@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"]} - - -# ───────────────────────────────────────────────────────────────────────────── -# WebUI auth (email + password) -# ───────────────────────────────────────────────────────────────────────────── - - -class RegisterRequest(BaseModel): - email: EmailStr - name: str = Field(..., min_length=1, max_length=128) - password: str = Field(..., min_length=8, max_length=128) - - -class LoginRequest(BaseModel): - email: EmailStr - password: str = Field(..., min_length=1, max_length=128) - - -class TokenPairResponse(BaseModel): - """JWT pair returned by register/login.""" - - access_token: str - refresh_token: str - token_type: str = "bearer" - expires_in: int # access TTL in seconds - user: WebUserPublic - - -class LogoutRequest(BaseModel): - refresh_token: str = Field(..., min_length=1) - - -class ForgotPasswordRequest(BaseModel): - email: EmailStr - - -class ResetPasswordRequest(BaseModel): - token: str = Field(..., min_length=1, max_length=256) - password: str = Field(..., min_length=8, max_length=128) - - -class OkResponse(BaseModel): - """Generic `{ok: true, ...}` payload for state-mutating auth endpoints.""" - - ok: bool = True - detail: str | None = None - - -def _require_access_claims(authorization: str | None) -> Any: - """Shared bearer-extraction used by `me` and any future stateless endpoint.""" - 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: - return verify_access_token(token) - except AuthError as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc - - -def _web_user_public(user: User) -> WebUserPublic: - return WebUserPublic( - id=str(user.id), - email=user.email, - name=user.name, - telegram_id=user.telegram_id, - credits_left=user.credits_left, - is_active=bool(user.is_active), - created_at=user.created_at, - ) - - -async def _issue_pair(user: User, refresh_store: RefreshStoreDep) -> TokenPairResponse: - """Mint an access + refresh pair for a verified user.""" - settings = get_settings() - access = create_access_token(user.id, user.telegram_id or 0) - jti = await refresh_store.issue(user.id) - refresh = create_refresh_token(user.id, jti) - return TokenPairResponse( - access_token=access, - refresh_token=refresh, - token_type="bearer", - expires_in=settings.jwt_access_ttl_minutes * 60, - user=_web_user_public(user), - ) - - -def _hash_reset_token(token: str) -> str: - """SHA-256 of a reset token — store this, never the raw token.""" - return hashlib.sha256(token.encode("utf-8")).hexdigest() - - -def _build_reset_link(token: str) -> str: - base = get_settings().web_app_base_url.rstrip("/") - return f"{base}/reset-password?token={token}" - - -@router.post( - "/api/v1/auth/register", - response_model=TokenPairResponse, - status_code=status.HTTP_201_CREATED, -) -async def register( - session: AsyncSessionDep, - refresh_store: RefreshStoreDep, - body: RegisterRequest, -) -> TokenPairResponse: - """Register a new email/password user and issue a JWT pair.""" - _require_web_auth_enabled() - email_normalized = body.email.lower().strip() - - existing = await fetch_user_by_email(session, email_normalized) - if existing is not None: - # Do not leak which emails are registered. - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email already registered") - - hashed = hash_password(body.password) - user = await create_email_user( - session, email=email_normalized, name=body.name.strip(), password_hash=hashed - ) - log.info("user_registered", user_id=str(user.id), email=email_normalized) - return await _issue_pair(user, refresh_store) - - -@router.post( - "/api/v1/auth/login", - response_model=TokenPairResponse, - status_code=status.HTTP_200_OK, -) -async def login( - session: AsyncSessionDep, - refresh_store: RefreshStoreDep, - body: LoginRequest, -) -> TokenPairResponse: - """Email + password -> JWT pair.""" - _require_web_auth_enabled() - email_normalized = body.email.lower().strip() - - user = await fetch_user_by_email(session, email_normalized) - if user is None or not user.password_hash: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials") - if not verify_password(body.password, user.password_hash): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials") - if not user.is_active: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled") - - log.info("user_logged_in", user_id=str(user.id)) - return await _issue_pair(user, refresh_store) - - -@router.post( - "/api/v1/auth/logout", - response_model=OkResponse, - status_code=status.HTTP_200_OK, -) -async def logout( - refresh_store: RefreshStoreDep, - body: LogoutRequest, -) -> OkResponse: - """Revoke the supplied refresh token. Access token stays valid until it expires.""" - _require_web_auth_enabled() - try: - claims = verify_refresh_token(body.refresh_token) - except AuthError as exc: - # Already expired or invalid — nothing to revoke. Return ok for idempotency. - log.info("logout_invalid_refresh", error=str(exc)) - return OkResponse(ok=True, detail="already revoked") - removed = await refresh_store.revoke(claims.sub, claims.jti) - log.info("user_logged_out", user_id=str(claims.sub), removed=removed) - return OkResponse(ok=True, detail="refresh revoked") - - -@router.post( - "/api/v1/auth/forgot-password", - response_model=OkResponse, - status_code=status.HTTP_202_ACCEPTED, -) -async def forgot_password( - session: AsyncSessionDep, - publisher: NotificationPublisherDep, - body: ForgotPasswordRequest, -) -> OkResponse: - """Generate a reset token, store its hash + expiry, and enqueue a notification. - - Always returns 202 with `ok=true` regardless of whether the email exists, - to avoid leaking which addresses are registered. - """ - _require_web_auth_enabled() - email_normalized = body.email.lower().strip() - - user = await fetch_user_by_email(session, email_normalized) - if user is None: - log.info("forgot_password_unknown_email", email=email_normalized) - return OkResponse(ok=True, detail="if the email exists, a reset link was sent") - - settings = get_settings() - raw_token = secrets.token_urlsafe(32) - token_hash = _hash_reset_token(raw_token) - expires_at = dt.datetime.now(tz=dt.UTC) + dt.timedelta( - minutes=settings.password_reset_ttl_minutes - ) - - from sqlalchemy import text as sa_text - - await session.execute( - sa_text( - "UPDATE users " - "SET password_reset_token_hash = :h, password_reset_expires_at = :e " - "WHERE id = :u" - ), - {"h": token_hash, "e": expires_at, "u": user.id}, - ) - await session.commit() - - reset_link = _build_reset_link(raw_token) - notification = NotificationMessage( - correlation_id=uuid.uuid4(), - kind="password_reset", - to=email_normalized, - subject="Восстановление пароля — Контракт-чек", - body_text=( - "Вы запросили сброс пароля.\n\n" - f"Перейдите по ссылке, чтобы задать новый пароль (действует " - f"{settings.password_reset_ttl_minutes} мин.):\n{reset_link}\n\n" - "Если вы не запрашивали сброс — просто проигнорируйте это письмо." - ), - body_html=( - "

Вы запросили сброс пароля.

" - f'

Задать новый пароль ' - f"(действует {settings.password_reset_ttl_minutes} мин.)

" - "

Если вы не запрашивали сброс — проигнорируйте это письмо.

" - ), - ) - try: - await publisher.publish(notification, routing_key="notify") - except Exception as exc: # noqa: BLE001 — best-effort; reset is still storable - log.error( - "forgot_password_publish_failed", - user_id=str(user.id), - error=str(exc), - ) - - log.info("forgot_password_enqueued", user_id=str(user.id)) - return OkResponse(ok=True, detail="if the email exists, a reset link was sent") - - -@router.post( - "/api/v1/auth/reset-password", - response_model=OkResponse, - status_code=status.HTTP_200_OK, -) -async def reset_password( - session: AsyncSessionDep, - refresh_store: RefreshStoreDep, - body: ResetPasswordRequest, -) -> OkResponse: - """Verify a reset token and set the new password. - - On success: clears the stored token hash, rotates the password, and revokes - all active refresh tokens for the user (forcing re-login everywhere). - """ - _require_web_auth_enabled() - token_hash = _hash_reset_token(body.token) - - from sqlalchemy import text as sa_text - - result = await session.execute( - sa_text( - "SELECT id, password_reset_expires_at FROM users WHERE password_reset_token_hash = :h" - ), - {"h": token_hash}, - ) - row = result.first() - if row is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid reset token") - - user_id = row[0] - expires_at = row[1] - now = dt.datetime.now(tz=dt.UTC) - if expires_at is None or expires_at < now: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="reset token expired") - - new_hash = hash_password(body.password) - await session.execute( - sa_text( - "UPDATE users " - "SET password_hash = :p, password_reset_token_hash = NULL, " - " password_reset_expires_at = NULL " - "WHERE id = :u" - ), - {"p": new_hash, "u": user_id}, - ) - await session.commit() - - # Best-effort revoke of existing sessions; ignore Redis hiccups so the - # password itself is still rotated. - try: - await refresh_store.revoke_all(user_id) - except Exception as exc: # noqa: BLE001 - log.warning("reset_password_revoke_failed", user_id=str(user_id), error=str(exc)) - - log.info("user_password_reset", user_id=str(user_id)) - return OkResponse(ok=True, detail="password updated") - - -def _require_web_auth_enabled() -> None: - """Gate webUI endpoints behind a feature flag (WEB_AUTH_ENABLED).""" - if not get_settings().web_auth_enabled: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="web auth disabled") - - -# ───────────────────────────────────────────────────────────────────────────── -# Passkeys (WebAuthn) -# ───────────────────────────────────────────────────────────────────────────── - - -class PasskeyRpInfo(BaseModel): - id: str - name: str - origin: str - - -class PasskeyUserInfo(BaseModel): - id: str - name: str - displayName: str - - -class PubKeyCredParam(BaseModel): - alg: int - type: str - - -class CredDescriptor(BaseModel): - id: str - type: str - - -class AuthenticatorSelectionInfo(BaseModel): - residentKey: str - userVerification: str - - -class PasskeyRegistrationOptions(BaseModel): - """PublicKeyCredentialCreationOptions — matches the frontend zod schema.""" - - challenge: str - rp: PasskeyRpInfo - user: PasskeyUserInfo - pubKeyCredParams: list[PubKeyCredParam] - timeout: int - attestation: str - excludeCredentials: list[CredDescriptor] - authenticatorSelection: AuthenticatorSelectionInfo - - -class PasskeyAuthenticationOptions(BaseModel): - """PublicKeyCredentialRequestOptions — matches the frontend zod schema.""" - - challenge: str - timeout: int - rpId: str - allowCredentials: list[CredDescriptor] - userVerification: str - - -class PasskeyCredentialPublic(BaseModel): - """Stored credential — matches passkeyCredentialSchema on the frontend.""" - - id: str - credentialID: str - credentialPublicKey: str - counter: int - userId: str - deviceName: str - createdAt: dt.datetime - - -class PasskeyRegistrationFinishResponse(BaseModel): - verified: bool - credentialId: str - - -class SingleTokenAuthResponse(BaseModel): - """Shared by passkey-auth finish and magic-link verify (frontend schemas).""" - - access_token: str - token_type: str = "bearer" - expires_in: int - user_id: uuid.UUID - email: str - - -class MagicLinkResponse(BaseModel): - """Matches magicLinkResponseSchema on the frontend.""" - - success: bool = True - message: str - expires_in: int # link TTL in seconds - - -class PasskeyRegisterStartRequest(BaseModel): - device_name: str | None = Field(default=None, max_length=128) - - -class RegistrationCredentialData(BaseModel): - """`navigator.credentials.create()` result, base64url-encoded.""" - - clientDataJSON: str - attestationObject: str - transports: list[str] | None = None - - -class RegistrationCredentialPayload(BaseModel): - id: str - rawId: str - type: str - response: RegistrationCredentialData - - -class PasskeyRegisterFinishRequest(BaseModel): - credential: RegistrationCredentialPayload - device_name: str | None = Field(default=None, max_length=128) - - -class AuthenticationCredentialData(BaseModel): - """`navigator.credentials.get()` result, base64url-encoded.""" - - clientDataJSON: str - authenticatorData: str - signature: str - userHandle: str | None = None - - -class AuthenticationCredentialPayload(BaseModel): - id: str - rawId: str - type: str - response: AuthenticationCredentialData - - -class PasskeyAuthenticateFinishRequest(BaseModel): - challenge: str = Field(..., min_length=16, max_length=512) - credential: AuthenticationCredentialPayload - - -def _require_passkey_enabled() -> None: - """Gate passkey endpoints behind a feature flag (PASSKEY_ENABLED).""" - if not get_settings().passkey_enabled: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="passkeys disabled") - - -def _require_magic_link_enabled() -> None: - """Gate magic-link endpoints behind a feature flag (MAGIC_LINK_ENABLED).""" - if not get_settings().magic_link_enabled: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="magic links disabled") - - -def _issue_single_token(user: User) -> SingleTokenAuthResponse: - """Mint one access JWT (no refresh) for passwordless login flows.""" - settings = get_settings() - return SingleTokenAuthResponse( - access_token=create_access_token(user.id, user.telegram_id or 0), - token_type="bearer", - expires_in=settings.jwt_access_ttl_minutes * 60, - user_id=user.id, - email=user.email or "", - ) - - -@router.post( - "/api/v1/auth/passkeys/register/start", - response_model=PasskeyRegistrationOptions, - status_code=status.HTTP_200_OK, -) -async def passkeys_register_start( - user: CurrentUserDep, - session: AsyncSessionDep, - challenges: PasskeyChallengeStoreDep, - body: PasskeyRegisterStartRequest | None = None, -) -> PasskeyRegistrationOptions: - """Begin passkey registration for the authenticated user. - - Returns PublicKeyCredentialCreationOptions (resident key + user - verification required, existing credentials excluded) and stores the - challenge in Redis keyed by the user id. - """ - _require_passkey_enabled() - full_user = await fetch_user_by_id_full(session, user.user_id) - if full_user is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") - - existing = await fetch_passkey_credentials_for_user(session, user.user_id) - options, challenge = build_registration_options( - settings=get_settings(), - user_id=full_user.id, - user_name=full_user.email or str(full_user.id), - existing_credential_ids=[cred.credential_id for cred in existing], - ) - await challenges.store_registration( - user.user_id, - challenge=challenge, - device_name=body.device_name if body else None, - ) - return PasskeyRegistrationOptions(**options) - - -@router.post( - "/api/v1/auth/passkeys/register/finish", - response_model=PasskeyRegistrationFinishResponse, - status_code=status.HTTP_201_CREATED, -) -async def passkeys_register_finish( - user: CurrentUserDep, - session: AsyncSessionDep, - challenges: PasskeyChallengeStoreDep, - body: PasskeyRegisterFinishRequest, -) -> PasskeyRegistrationFinishResponse: - """Verify the attestation and persist the credential. - - The challenge is consumed from Redis (single use). A credential id - already registered on any account is rejected with 409. - """ - _require_passkey_enabled() - settings = get_settings() - - stored = await challenges.consume_registration(user.user_id) - if stored is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="no pending registration challenge (expired or already used)", - ) - - try: - verified = verify_registration_response( - credential=body.credential.model_dump(exclude_none=True), - expected_challenge=base64url_to_bytes(stored["challenge"]), - expected_origin=settings.passkey_rp_origins, - expected_rp_id=settings.passkey_rp_id, - require_user_verification=True, - ) - except InvalidRegistrationResponse as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid registration: {exc}" - ) from exc - - credential_id = bytes_to_base64url(verified.credential_id) - duplicate = await fetch_passkey_by_credential_id(session, credential_id) - if duplicate is not None: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="credential already registered" - ) - - session.add( - PasskeyCredential( - user_id=user.user_id, - credential_id=credential_id, - public_key=bytes_to_base64url(verified.credential_public_key), - sign_count=verified.sign_count, - device_name=body.device_name or stored.get("device_name"), - ) - ) - await session.commit() - log.info("passkey_registered", user_id=str(user.user_id), credential_id=credential_id) - return PasskeyRegistrationFinishResponse(verified=True, credentialId=credential_id) - - -@router.post( - "/api/v1/auth/passkeys/authenticate/start", - response_model=PasskeyAuthenticationOptions, - status_code=status.HTTP_200_OK, -) -async def passkeys_authenticate_start( - challenges: PasskeyChallengeStoreDep, -) -> PasskeyAuthenticationOptions: - """Begin discoverable-credential (passkey) login. - - `allowCredentials` is empty — the authenticator offers its resident keys - and the user is resolved on finish from the returned credential id. - The challenge is stored in Redis under its own base64url value. - """ - _require_passkey_enabled() - options, challenge = build_authentication_options(settings=get_settings()) - await challenges.store_authentication(challenge) - return PasskeyAuthenticationOptions(**options) - - -@router.post( - "/api/v1/auth/passkeys/authenticate/finish", - response_model=SingleTokenAuthResponse, - status_code=status.HTTP_200_OK, -) -async def passkeys_authenticate_finish( - session: AsyncSessionDep, - challenges: PasskeyChallengeStoreDep, - body: PasskeyAuthenticateFinishRequest, -) -> SingleTokenAuthResponse: - """Verify the assertion and issue an access JWT. - - The echoed challenge must still be pending in Redis (single use); the - credential is resolved to its owner, the signature is checked against - the stored public key, and the sign counter is rotated. - """ - _require_passkey_enabled() - settings = get_settings() - - if not await challenges.consume_authentication(body.challenge): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="unknown or expired challenge", - ) - - record = await fetch_passkey_by_credential_id(session, body.credential.id) - if record is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unknown credential") - - owner = await fetch_user_by_id_full(session, record.user_id) - if owner is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") - if not owner.is_active: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled") - - try: - verified = verify_authentication_response( - credential=body.credential.model_dump(exclude_none=True), - expected_challenge=base64url_to_bytes(body.challenge), - expected_origin=settings.passkey_rp_origins, - expected_rp_id=settings.passkey_rp_id, - credential_public_key=base64url_to_bytes(record.public_key), - credential_current_sign_count=record.sign_count, - require_user_verification=True, - ) - except InvalidAuthenticationResponse as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid authentication: {exc}" - ) from exc - - record.sign_count = verified.new_sign_count - record.last_used_at = dt.datetime.now(tz=dt.UTC) - await session.commit() - - log.info("user_logged_in_passkey", user_id=str(owner.id)) - return _issue_single_token(owner) - - -@router.get( - "/api/v1/auth/passkeys", - response_model=list[PasskeyCredentialPublic], - status_code=status.HTTP_200_OK, -) -async def passkeys_list( - user: CurrentUserDep, - session: AsyncSessionDep, -) -> list[PasskeyCredentialPublic]: - """List the authenticated user's registered passkeys (newest first).""" - _require_passkey_enabled() - creds = await fetch_passkey_credentials_for_user(session, user.user_id) - return [ - PasskeyCredentialPublic( - id=str(cred.id), - credentialID=cred.credential_id, - credentialPublicKey=cred.public_key, - counter=cred.sign_count, - userId=str(cred.user_id), - deviceName=cred.device_name or "", - createdAt=cred.created_at, - ) - for cred in creds - ] - - -@router.delete( - "/api/v1/auth/passkeys/{passkey_id}", - response_model=OkResponse, - status_code=status.HTTP_200_OK, -) -async def passkeys_delete( - user: CurrentUserDep, - session: AsyncSessionDep, - passkey_id: uuid.UUID, -) -> OkResponse: - """Remove one of the user's passkeys. Scoped to the owning user.""" - _require_passkey_enabled() - - from sqlalchemy import text as sa_text - - result = await session.execute( - sa_text("DELETE FROM passkey_credentials WHERE id = :i AND user_id = :u RETURNING id"), - {"i": passkey_id, "u": user.user_id}, - ) - if result.first() is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="passkey not found") - await session.commit() - log.info("passkey_deleted", user_id=str(user.user_id), passkey_id=str(passkey_id)) - return OkResponse(ok=True, detail="passkey deleted") - - -# ───────────────────────────────────────────────────────────────────────────── -# Magic-link auth (passwordless email login) -# ───────────────────────────────────────────────────────────────────────────── - - -class MagicLinkRequest(BaseModel): - email: EmailStr - - -class MagicLinkVerifyRequest(BaseModel): - token: str = Field(..., min_length=1, max_length=256) - - -def _build_magic_link(token: str) -> str: - base = get_settings().web_app_base_url.rstrip("/") - return f"{base}/magic-link?token={token}" - - -@router.post( - "/api/v1/auth/magic-link/request", - response_model=MagicLinkResponse, - status_code=status.HTTP_200_OK, -) -async def magic_link_request( - session: AsyncSessionDep, - publisher: NotificationPublisherDep, - body: MagicLinkRequest, -) -> MagicLinkResponse: - """Generate a one-time login token, store its hash, and enqueue a notification. - - Always returns the same 200 payload regardless of whether the email - exists, to avoid leaking which addresses are registered (mirrors - forgot-password). - """ - _require_magic_link_enabled() - email_normalized = body.email.lower().strip() - settings = get_settings() - generic = MagicLinkResponse( - success=True, - message="if the email exists, a magic link was sent", - expires_in=settings.magic_link_ttl_minutes * 60, - ) - - user = await fetch_user_by_email(session, email_normalized) - if user is None: - log.info("magic_link_unknown_email", email=email_normalized) - return generic - - raw_token = secrets.token_urlsafe(32) - expires_at = dt.datetime.now(tz=dt.UTC) + dt.timedelta(minutes=settings.magic_link_ttl_minutes) - - from sqlalchemy import text as sa_text - - await session.execute( - sa_text( - "UPDATE users SET magic_link_token_hash = :h, magic_link_expires_at = :e WHERE id = :u" - ), - {"h": _hash_reset_token(raw_token), "e": expires_at, "u": user.id}, - ) - await session.commit() - - link = _build_magic_link(raw_token) - notification = NotificationMessage( - correlation_id=uuid.uuid4(), - kind="magic_link", - to=email_normalized, - subject="Вход по ссылке — Контракт-чек", - body_text=( - "Вы запросили вход по ссылке.\n\n" - f"Перейдите по ссылке, чтобы войти (действует " - f"{settings.magic_link_ttl_minutes} мин.):\n{link}\n\n" - "Ссылка одноразовая. Если вы не запрашивали вход — просто " - "проигнорируйте это письмо." - ), - body_html=( - "

Вы запросили вход по ссылке.

" - f'

Войти ' - f"(действует {settings.magic_link_ttl_minutes} мин., ссылка одноразовая)

" - "

Если вы не запрашивали вход — проигнорируйте это письмо.

" - ), - ) - try: - await publisher.publish(notification, routing_key="notify") - except Exception as exc: # noqa: BLE001 — best-effort; token is still storable - log.error( - "magic_link_publish_failed", - user_id=str(user.id), - error=str(exc), - ) - - log.info("magic_link_enqueued", user_id=str(user.id)) - return generic - - -@router.post( - "/api/v1/auth/magic-link/verify", - response_model=SingleTokenAuthResponse, - status_code=status.HTTP_200_OK, -) -async def magic_link_verify( - session: AsyncSessionDep, - body: MagicLinkVerifyRequest, -) -> SingleTokenAuthResponse: - """Exchange a magic-link token for an access JWT. - - On success the stored token hash is cleared, so every link works exactly - once. Expired tokens are also cleared (they can never be replayed). - """ - _require_magic_link_enabled() - token_hash = _hash_reset_token(body.token) - - from sqlalchemy import text as sa_text - - result = await session.execute( - sa_text("SELECT id, magic_link_expires_at FROM users WHERE magic_link_token_hash = :h"), - {"h": token_hash}, - ) - row = result.first() - if row is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="invalid magic-link token" - ) - - user_id, expires_at = row[0], row[1] - now = dt.datetime.now(tz=dt.UTC) - if expires_at is None or expires_at < now: - await session.execute( - sa_text( - "UPDATE users " - "SET magic_link_token_hash = NULL, magic_link_expires_at = NULL " - "WHERE id = :u" - ), - {"u": user_id}, - ) - await session.commit() - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="magic-link token expired" - ) - - await session.execute( - sa_text( - "UPDATE users " - "SET magic_link_token_hash = NULL, magic_link_expires_at = NULL " - "WHERE id = :u" - ), - {"u": user_id}, - ) - await session.commit() - - user = await fetch_user_by_id_full(session, user_id) - if user is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") - if not user.is_active: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled") - - log.info("user_logged_in_magic_link", user_id=str(user.id)) - return _issue_single_token(user) diff --git a/src/contract_check/api/routes/auth/__init__.py b/src/contract_check/api/routes/auth/__init__.py new file mode 100644 index 0000000..57056b2 --- /dev/null +++ b/src/contract_check/api/routes/auth/__init__.py @@ -0,0 +1,13 @@ +"""Authentication routes package: composes all auth sub-routers under /api/v1/auth.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from . import magic_links, passkeys, password, telegram + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) +router.include_router(telegram.router) +router.include_router(password.router) +router.include_router(passkeys.router) +router.include_router(magic_links.router) diff --git a/src/contract_check/api/routes/auth/magic_links.py b/src/contract_check/api/routes/auth/magic_links.py new file mode 100644 index 0000000..73fa59c --- /dev/null +++ b/src/contract_check/api/routes/auth/magic_links.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import datetime as dt +import secrets +import uuid + +from fastapi import APIRouter, HTTPException, status + +from ....core.config import get_settings +from ....core.db.repositories import UserRepository +from ....core.logging import get_logger +from ....core.mq.messages import NotificationMessage +from ...deps import ( + AsyncSessionDep, + NotificationPublisherDep, + fetch_user_by_email, + fetch_user_by_id_full, +) +from ...schemas import ( + MagicLinkRequest, + MagicLinkResponse, + MagicLinkVerifyRequest, + SingleTokenAuthResponse, +) +from .support import ( + _build_magic_link, + _hash_reset_token, + _issue_single_token, + _require_magic_link_enabled, +) + +log = get_logger(__name__) +router = APIRouter(tags=["auth"]) + + +@router.post( + "/magic-link/request", + response_model=MagicLinkResponse, + status_code=status.HTTP_200_OK, +) +async def magic_link_request( + session: AsyncSessionDep, + publisher: NotificationPublisherDep, + body: MagicLinkRequest, +) -> MagicLinkResponse: + """Generate a one-time login token, store its hash, and enqueue a notification. + + Always returns the same 200 payload regardless of whether the email + exists, to avoid leaking which addresses are registered (mirrors + forgot-password). + """ + _require_magic_link_enabled() + email_normalized = body.email.lower().strip() + settings = get_settings() + generic = MagicLinkResponse( + success=True, + message="if the email exists, a magic link was sent", + expires_in=settings.magic_link_ttl_minutes * 60, + ) + + user = await fetch_user_by_email(session, email_normalized) + if user is None: + log.info("magic_link_unknown_email", email=email_normalized) + return generic + + raw_token = secrets.token_urlsafe(32) + expires_at = dt.datetime.now(tz=dt.UTC) + dt.timedelta(minutes=settings.magic_link_ttl_minutes) + + await UserRepository(session).set_magic_link_token( + user.id, + token_hash=_hash_reset_token(raw_token), + expires_at=expires_at, + ) + await session.commit() + + link = _build_magic_link(raw_token) + notification = NotificationMessage( + correlation_id=uuid.uuid4(), + kind="magic_link", + to=email_normalized, + subject="Вход по ссылке — Контракт-чек", + body_text=( + "Вы запросили вход по ссылке.\n\n" + f"Перейдите по ссылке, чтобы войти (действует " + f"{settings.magic_link_ttl_minutes} мин.):\n{link}\n\n" + "Ссылка одноразовая. Если вы не запрашивали вход — просто " + "проигнорируйте это письмо." + ), + body_html=( + "

Вы запросили вход по ссылке.

" + f'

Войти ' + f"(действует {settings.magic_link_ttl_minutes} мин., ссылка одноразовая)

" + "

Если вы не запрашивали вход — проигнорируйте это письмо.

" + ), + ) + try: + await publisher.publish(notification, routing_key="notify") + except Exception as exc: # noqa: BLE001 — best-effort; token is still storable + log.error( + "magic_link_publish_failed", + user_id=str(user.id), + error=str(exc), + ) + + log.info("magic_link_enqueued", user_id=str(user.id)) + return generic + + +@router.post( + "/magic-link/verify", + response_model=SingleTokenAuthResponse, + status_code=status.HTTP_200_OK, +) +async def magic_link_verify( + session: AsyncSessionDep, + body: MagicLinkVerifyRequest, +) -> SingleTokenAuthResponse: + """Exchange a magic-link token for an access JWT. + + On success the stored token hash is cleared, so every link works exactly + once. Expired tokens are also cleared (they can never be replayed). + """ + _require_magic_link_enabled() + token_hash = _hash_reset_token(body.token) + + users = UserRepository(session) + token_user = await users.get_by_magic_link_token_hash(token_hash) + if token_user is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="invalid magic-link token" + ) + + user_id = token_user.id + expires_at = token_user.magic_link_expires_at + now = dt.datetime.now(tz=dt.UTC) + if expires_at is None or expires_at < now: + await users.consume_magic_link_token(user_id) + await session.commit() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="magic-link token expired" + ) + + await users.consume_magic_link_token(user_id) + await session.commit() + + user = await fetch_user_by_id_full(session, user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled") + + log.info("user_logged_in_magic_link", user_id=str(user.id)) + return _issue_single_token(user) diff --git a/src/contract_check/api/routes/auth/passkeys.py b/src/contract_check/api/routes/auth/passkeys.py new file mode 100644 index 0000000..88daea2 --- /dev/null +++ b/src/contract_check/api/routes/auth/passkeys.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import datetime as dt +import uuid + +from fastapi import APIRouter, HTTPException, status +from webauthn import verify_authentication_response, verify_registration_response +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url +from webauthn.helpers.exceptions import InvalidAuthenticationResponse, InvalidRegistrationResponse + +from ....core.config import get_settings +from ....core.db.models import PasskeyCredential +from ....core.db.repositories import PasskeyRepository +from ....core.logging import get_logger +from ....core.passkeys import build_authentication_options, build_registration_options +from ...deps import ( + AsyncSessionDep, + CurrentUserDep, + PasskeyChallengeStoreDep, + fetch_passkey_by_credential_id, + fetch_passkey_credentials_for_user, + fetch_user_by_id_full, +) +from ...schemas import ( + OkResponse, + PasskeyAuthenticateFinishRequest, + PasskeyAuthenticationOptions, + PasskeyCredentialPublic, + PasskeyRegisterFinishRequest, + PasskeyRegisterStartRequest, + PasskeyRegistrationFinishResponse, + PasskeyRegistrationOptions, + SingleTokenAuthResponse, +) +from .support import _issue_single_token, _require_passkey_enabled + +log = get_logger(__name__) +router = APIRouter(tags=["auth"]) + + +@router.post( + "/passkeys/register/start", + response_model=PasskeyRegistrationOptions, + status_code=status.HTTP_200_OK, +) +async def passkeys_register_start( + user: CurrentUserDep, + session: AsyncSessionDep, + challenges: PasskeyChallengeStoreDep, + body: PasskeyRegisterStartRequest | None = None, +) -> PasskeyRegistrationOptions: + """Begin passkey registration for the authenticated user. + + Returns PublicKeyCredentialCreationOptions (resident key + user + verification required, existing credentials excluded) and stores the + challenge in Redis keyed by the user id. + """ + _require_passkey_enabled() + full_user = await fetch_user_by_id_full(session, user.user_id) + if full_user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") + + existing = await fetch_passkey_credentials_for_user(session, user.user_id) + options, challenge = build_registration_options( + settings=get_settings(), + user_id=full_user.id, + user_name=full_user.email or str(full_user.id), + existing_credential_ids=[cred.credential_id for cred in existing], + ) + await challenges.store_registration( + user.user_id, + challenge=challenge, + device_name=body.device_name if body else None, + ) + return PasskeyRegistrationOptions(**options) + + +@router.post( + "/passkeys/register/finish", + response_model=PasskeyRegistrationFinishResponse, + status_code=status.HTTP_201_CREATED, +) +async def passkeys_register_finish( + user: CurrentUserDep, + session: AsyncSessionDep, + challenges: PasskeyChallengeStoreDep, + body: PasskeyRegisterFinishRequest, +) -> PasskeyRegistrationFinishResponse: + """Verify the attestation and persist the credential. + + The challenge is consumed from Redis (single use). A credential id + already registered on any account is rejected with 409. + """ + _require_passkey_enabled() + settings = get_settings() + + stored = await challenges.consume_registration(user.user_id) + if stored is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="no pending registration challenge (expired or already used)", + ) + + try: + verified = verify_registration_response( + credential=body.credential.model_dump(exclude_none=True), + expected_challenge=base64url_to_bytes(stored["challenge"]), + expected_origin=settings.passkey_rp_origins, + expected_rp_id=settings.passkey_rp_id, + require_user_verification=True, + ) + except InvalidRegistrationResponse as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid registration: {exc}" + ) from exc + + credential_id = bytes_to_base64url(verified.credential_id) + duplicate = await fetch_passkey_by_credential_id(session, credential_id) + if duplicate is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="credential already registered" + ) + + session.add( + PasskeyCredential( + user_id=user.user_id, + credential_id=credential_id, + public_key=bytes_to_base64url(verified.credential_public_key), + sign_count=verified.sign_count, + device_name=body.device_name or stored.get("device_name"), + ) + ) + await session.commit() + log.info("passkey_registered", user_id=str(user.user_id), credential_id=credential_id) + return PasskeyRegistrationFinishResponse(verified=True, credentialId=credential_id) + + +@router.post( + "/passkeys/authenticate/start", + response_model=PasskeyAuthenticationOptions, + status_code=status.HTTP_200_OK, +) +async def passkeys_authenticate_start( + challenges: PasskeyChallengeStoreDep, +) -> PasskeyAuthenticationOptions: + """Begin discoverable-credential (passkey) login. + + `allowCredentials` is empty — the authenticator offers its resident keys + and the user is resolved on finish from the returned credential id. + The challenge is stored in Redis under its own base64url value. + """ + _require_passkey_enabled() + options, challenge = build_authentication_options(settings=get_settings()) + await challenges.store_authentication(challenge) + return PasskeyAuthenticationOptions(**options) + + +@router.post( + "/passkeys/authenticate/finish", + response_model=SingleTokenAuthResponse, + status_code=status.HTTP_200_OK, +) +async def passkeys_authenticate_finish( + session: AsyncSessionDep, + challenges: PasskeyChallengeStoreDep, + body: PasskeyAuthenticateFinishRequest, +) -> SingleTokenAuthResponse: + """Verify the assertion and issue an access JWT. + + The echoed challenge must still be pending in Redis (single use); the + credential is resolved to its owner, the signature is checked against + the stored public key, and the sign counter is rotated. + """ + _require_passkey_enabled() + settings = get_settings() + + if not await challenges.consume_authentication(body.challenge): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="unknown or expired challenge", + ) + + record = await fetch_passkey_by_credential_id(session, body.credential.id) + if record is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unknown credential") + + owner = await fetch_user_by_id_full(session, record.user_id) + if owner is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") + if not owner.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled") + + try: + verified = verify_authentication_response( + credential=body.credential.model_dump(exclude_none=True), + expected_challenge=base64url_to_bytes(body.challenge), + expected_origin=settings.passkey_rp_origins, + expected_rp_id=settings.passkey_rp_id, + credential_public_key=base64url_to_bytes(record.public_key), + credential_current_sign_count=record.sign_count, + require_user_verification=True, + ) + except InvalidAuthenticationResponse as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid authentication: {exc}" + ) from exc + + record.sign_count = verified.new_sign_count + record.last_used_at = dt.datetime.now(tz=dt.UTC) + await session.commit() + + log.info("user_logged_in_passkey", user_id=str(owner.id)) + return _issue_single_token(owner) + + +@router.get( + "/passkeys", + response_model=list[PasskeyCredentialPublic], + status_code=status.HTTP_200_OK, +) +async def passkeys_list( + user: CurrentUserDep, + session: AsyncSessionDep, +) -> list[PasskeyCredentialPublic]: + """List the authenticated user's registered passkeys (newest first).""" + _require_passkey_enabled() + creds = await fetch_passkey_credentials_for_user(session, user.user_id) + return [ + PasskeyCredentialPublic( + id=str(cred.id), + credentialID=cred.credential_id, + credentialPublicKey=cred.public_key, + counter=cred.sign_count, + userId=str(cred.user_id), + deviceName=cred.device_name or "", + createdAt=cred.created_at, + ) + for cred in creds + ] + + +@router.delete( + "/passkeys/{passkey_id}", + response_model=OkResponse, + status_code=status.HTTP_200_OK, +) +async def passkeys_delete( + user: CurrentUserDep, + session: AsyncSessionDep, + passkey_id: uuid.UUID, +) -> OkResponse: + """Remove one of the user's passkeys. Scoped to the owning user.""" + _require_passkey_enabled() + + deleted = await PasskeyRepository(session).delete_by_id_for_user(passkey_id, user.user_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="passkey not found") + await session.commit() + log.info("passkey_deleted", user_id=str(user.user_id), passkey_id=str(passkey_id)) + return OkResponse(ok=True, detail="passkey deleted") diff --git a/src/contract_check/api/routes/auth/password.py b/src/contract_check/api/routes/auth/password.py new file mode 100644 index 0000000..083dd99 --- /dev/null +++ b/src/contract_check/api/routes/auth/password.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import datetime as dt +import secrets +import uuid + +from fastapi import APIRouter, HTTPException, status + +from ....core.auth import AuthError, verify_refresh_token +from ....core.config import get_settings +from ....core.db.repositories import UserRepository +from ....core.logging import get_logger +from ....core.mq.messages import NotificationMessage +from ....core.security.passwords import hash_password, verify_password +from ...deps import ( + AsyncSessionDep, + NotificationPublisherDep, + RefreshStoreDep, + create_email_user, + fetch_user_by_email, +) +from ...schemas import ( + ForgotPasswordRequest, + LoginRequest, + LogoutRequest, + OkResponse, + RegisterRequest, + ResetPasswordRequest, + TokenPairResponse, +) +from .support import ( + _build_reset_link, + _hash_reset_token, + _issue_pair, + _require_web_auth_enabled, +) + +log = get_logger(__name__) +router = APIRouter(tags=["auth"]) + + +@router.post( + "/register", + response_model=TokenPairResponse, + status_code=status.HTTP_201_CREATED, +) +async def register( + session: AsyncSessionDep, + refresh_store: RefreshStoreDep, + body: RegisterRequest, +) -> TokenPairResponse: + """Register a new email/password user and issue a JWT pair.""" + _require_web_auth_enabled() + email_normalized = body.email.lower().strip() + + existing = await fetch_user_by_email(session, email_normalized) + if existing is not None: + # Do not leak which emails are registered. + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email already registered") + + hashed = hash_password(body.password) + user = await create_email_user( + session, email=email_normalized, name=body.name.strip(), password_hash=hashed + ) + await session.commit() + log.info("user_registered", user_id=str(user.id), email=email_normalized) + return await _issue_pair(user, refresh_store) + + +@router.post( + "/login", + response_model=TokenPairResponse, + status_code=status.HTTP_200_OK, +) +async def login( + session: AsyncSessionDep, + refresh_store: RefreshStoreDep, + body: LoginRequest, +) -> TokenPairResponse: + """Email + password -> JWT pair.""" + _require_web_auth_enabled() + email_normalized = body.email.lower().strip() + + user = await fetch_user_by_email(session, email_normalized) + if user is None or not user.password_hash: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials") + if not verify_password(body.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled") + + log.info("user_logged_in", user_id=str(user.id)) + return await _issue_pair(user, refresh_store) + + +@router.post( + "/logout", + response_model=OkResponse, + status_code=status.HTTP_200_OK, +) +async def logout( + refresh_store: RefreshStoreDep, + body: LogoutRequest, +) -> OkResponse: + """Revoke the supplied refresh token. Access token stays valid until it expires.""" + _require_web_auth_enabled() + try: + claims = verify_refresh_token(body.refresh_token) + except AuthError as exc: + # Already expired or invalid — nothing to revoke. Return ok for idempotency. + log.info("logout_invalid_refresh", error=str(exc)) + return OkResponse(ok=True, detail="already revoked") + removed = await refresh_store.revoke(claims.sub, claims.jti) + log.info("user_logged_out", user_id=str(claims.sub), removed=removed) + return OkResponse(ok=True, detail="refresh revoked") + + +@router.post( + "/forgot-password", + response_model=OkResponse, + status_code=status.HTTP_202_ACCEPTED, +) +async def forgot_password( + session: AsyncSessionDep, + publisher: NotificationPublisherDep, + body: ForgotPasswordRequest, +) -> OkResponse: + """Generate a reset token, store its hash + expiry, and enqueue a notification. + + Always returns 202 with `ok=true` regardless of whether the email exists, + to avoid leaking which addresses are registered. + """ + _require_web_auth_enabled() + email_normalized = body.email.lower().strip() + + user = await fetch_user_by_email(session, email_normalized) + if user is None: + log.info("forgot_password_unknown_email", email=email_normalized) + return OkResponse(ok=True, detail="if the email exists, a reset link was sent") + + settings = get_settings() + raw_token = secrets.token_urlsafe(32) + token_hash = _hash_reset_token(raw_token) + expires_at = dt.datetime.now(tz=dt.UTC) + dt.timedelta( + minutes=settings.password_reset_ttl_minutes + ) + + await UserRepository(session).set_password_reset_token( + user.id, + token_hash=token_hash, + expires_at=expires_at, + ) + await session.commit() + + reset_link = _build_reset_link(raw_token) + notification = NotificationMessage( + correlation_id=uuid.uuid4(), + kind="password_reset", + to=email_normalized, + subject="Восстановление пароля — Контракт-чек", + body_text=( + "Вы запросили сброс пароля.\n\n" + f"Перейдите по ссылке, чтобы задать новый пароль (действует " + f"{settings.password_reset_ttl_minutes} мин.):\n{reset_link}\n\n" + "Если вы не запрашивали сброс — просто проигнорируйте это письмо." + ), + body_html=( + "

Вы запросили сброс пароля.

" + f'

Задать новый пароль ' + f"(действует {settings.password_reset_ttl_minutes} мин.)

" + "

Если вы не запрашивали сброс — проигнорируйте это письмо.

" + ), + ) + try: + await publisher.publish(notification, routing_key="notify") + except Exception as exc: # noqa: BLE001 — best-effort; reset is still storable + log.error( + "forgot_password_publish_failed", + user_id=str(user.id), + error=str(exc), + ) + + log.info("forgot_password_enqueued", user_id=str(user.id)) + return OkResponse(ok=True, detail="if the email exists, a reset link was sent") + + +@router.post( + "/reset-password", + response_model=OkResponse, + status_code=status.HTTP_200_OK, +) +async def reset_password( + session: AsyncSessionDep, + refresh_store: RefreshStoreDep, + body: ResetPasswordRequest, +) -> OkResponse: + """Verify a reset token and set the new password. + + On success: clears the stored token hash, rotates the password, and revokes + all active refresh tokens for the user (forcing re-login everywhere). + """ + _require_web_auth_enabled() + token_hash = _hash_reset_token(body.token) + + users = UserRepository(session) + reset_window = await users.get_password_reset_window(token_hash) + if reset_window is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid reset token") + + user_id, expires_at = reset_window + now = dt.datetime.now(tz=dt.UTC) + if expires_at is None or expires_at < now: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="reset token expired") + + new_hash = hash_password(body.password) + await users.reset_password(user_id, password_hash=new_hash) + await session.commit() + + # Best-effort revoke of existing sessions; ignore Redis hiccups so the + # password itself is still rotated. + try: + await refresh_store.revoke_all(user_id) + except Exception as exc: # noqa: BLE001 + log.warning("reset_password_revoke_failed", user_id=str(user_id), error=str(exc)) + + log.info("user_password_reset", user_id=str(user_id)) + return OkResponse(ok=True, detail="password updated") diff --git a/src/contract_check/api/routes/auth/support.py b/src/contract_check/api/routes/auth/support.py new file mode 100644 index 0000000..ca071b1 --- /dev/null +++ b/src/contract_check/api/routes/auth/support.py @@ -0,0 +1,121 @@ +"""Shared auth route helpers (token minting, feature gates, link building).""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from fastapi import HTTPException, status + +from ....core.auth import AuthError, create_access_token, create_refresh_token, verify_access_token +from ....core.config import get_settings +from ....core.db.models import User +from ...deps import RefreshStoreDep +from ...schemas import ( + AuthResponse, + SingleTokenAuthResponse, + TelegramProfile, + TokenPairResponse, + WebUserPublic, +) + + +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, + ) + + +def _telegram_profile_looks_verified(profile: TelegramProfile | None) -> bool: + if profile is None: + return False + return bool(profile.username or profile.first_name or profile.last_name) + + +def _require_access_claims(authorization: str | None) -> Any: + """Shared bearer-extraction used by `me` and any future stateless endpoint.""" + 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: + return verify_access_token(token) + except AuthError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc + + +def _web_user_public(user: User) -> WebUserPublic: + return WebUserPublic( + id=str(user.id), + email=user.email, + name=user.name, + telegram_id=user.telegram_id, + credits_left=user.credits_left, + is_active=bool(user.is_active), + created_at=user.created_at, + ) + + +async def _issue_pair(user: User, refresh_store: RefreshStoreDep) -> TokenPairResponse: + """Mint an access + refresh pair for a verified user.""" + settings = get_settings() + access = create_access_token(user.id, user.telegram_id or 0) + jti = await refresh_store.issue(user.id) + refresh = create_refresh_token(user.id, jti) + return TokenPairResponse( + access_token=access, + refresh_token=refresh, + token_type="bearer", + expires_in=settings.jwt_access_ttl_minutes * 60, + user=_web_user_public(user), + ) + + +def _hash_reset_token(token: str) -> str: + """SHA-256 of a reset token — store this, never the raw token.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def _build_reset_link(token: str) -> str: + base = get_settings().web_app_base_url.rstrip("/") + return f"{base}/reset-password?token={token}" + + +def _require_web_auth_enabled() -> None: + """Gate webUI endpoints behind a feature flag (WEB_AUTH_ENABLED).""" + if not get_settings().web_auth_enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="web auth disabled") + + +def _require_passkey_enabled() -> None: + """Gate passkey endpoints behind a feature flag (PASSKEY_ENABLED).""" + if not get_settings().passkey_enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="passkeys disabled") + + +def _require_magic_link_enabled() -> None: + """Gate magic-link endpoints behind a feature flag (MAGIC_LINK_ENABLED).""" + if not get_settings().magic_link_enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="magic links disabled") + + +def _issue_single_token(user: User) -> SingleTokenAuthResponse: + """Mint one access JWT (no refresh) for passwordless login flows.""" + settings = get_settings() + return SingleTokenAuthResponse( + access_token=create_access_token(user.id, user.telegram_id or 0), + token_type="bearer", + expires_in=settings.jwt_access_ttl_minutes * 60, + user_id=user.id, + email=user.email or "", + ) + + +def _build_magic_link(token: str) -> str: + base = get_settings().web_app_base_url.rstrip("/") + return f"{base}/magic-link?token={token}" diff --git a/src/contract_check/api/routes/auth/telegram.py b/src/contract_check/api/routes/auth/telegram.py new file mode 100644 index 0000000..8ff0ef3 --- /dev/null +++ b/src/contract_check/api/routes/auth/telegram.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Header, HTTPException, status + +from ....core.auth import ( + AuthError, + verify_bot_identity, + verify_telegram_miniapp_init_data, + verify_telegram_web_payload, +) +from ....core.config import get_settings +from ....core.logging import get_logger +from ...deps import AsyncSessionDep, AuthDep, fetch_user_by_id_full, get_or_create_user_for_telegram +from ...schemas import ( + AuthResponse, + MeResponse, + TelegramBotAuthRequest, + TelegramMiniAppAuthRequest, + TelegramWebAuthRequest, +) +from .support import _issue_token, _require_access_claims, _telegram_profile_looks_verified + +log = get_logger(__name__) +router = APIRouter(tags=["auth"]) + + +@router.post("/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. + + Stores/updates the Telegram profile snapshot and sets telegram_verified + based on whether the profile looks legitimate. + """ + identity = verify_bot_identity(body.telegram_id) + profile = body.profile + verified = _telegram_profile_looks_verified(profile) + user = await get_or_create_user_for_telegram( + session, + identity.telegram_id, + profile=profile.model_dump(exclude_none=True) if profile else None, + verified=verified, + ) + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="account disabled") + return _issue_token(user) + + +@router.post("/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("/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("/me", response_model=MeResponse, status_code=status.HTTP_200_OK) +async def me( + session: AsyncSessionDep, + authorization: Annotated[str | None, Header()] = None, +) -> MeResponse: + """Current user — verifies the Bearer JWT and returns the user's profile. + + Backward-compatible with the previous introspect shape (sub/telegram_id/ + type/exp) and adds email/credits_left/is_active/created_at. + """ + claims = _require_access_claims(authorization) + user = await fetch_user_by_id_full(session, claims.sub) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") + + return MeResponse( + sub=str(user.id), + telegram_id=user.telegram_id or 0, + type=claims.type, + exp=claims.exp or 0, + email=user.email, + name=user.name, + credits_left=user.credits_left, + is_active=bool(user.is_active), + created_at=user.created_at, + ) + + +@router.get("/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 index 3d3638e..3424057 100644 --- a/src/contract_check/api/routes/b2b.py +++ b/src/contract_check/api/routes/b2b.py @@ -11,10 +11,9 @@ 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.db.repositories import ApiKeyRepository from ...core.logging import get_logger from ..deps import ( ApiKeyAuthDep, @@ -24,6 +23,17 @@ from ..deps import ( StorageDep, fetch_document_status_for_user, ) +from ..schemas import ( + ApiKeyCreatedResponse, + ApiKeyResponse, + ApiKeyUsageDetailResponse, + B2BUsageResponse, + CreateApiKeyRequest, + DocumentUploadResponse, + ReportInProgressResponse, + ReportResponse, + RevokeApiKeyResponse, +) from ..services import upload_and_enqueue log = get_logger(__name__) @@ -31,14 +41,16 @@ log = get_logger(__name__) router = APIRouter(tags=["b2b"]) -@router.post("/api/v1/analyze", status_code=status.HTTP_202_ACCEPTED) +@router.post( + "/api/v1/analyze", response_model=DocumentUploadResponse, status_code=status.HTTP_202_ACCEPTED +) async def analyze_document( auth: ApiKeyAuthDep, session: AsyncSessionDep, storage: StorageDep, publisher: PublisherDep, file: Annotated[UploadFile, File()], -) -> dict[str, object]: +) -> DocumentUploadResponse: """Upload a document for analysis using a B2B API key. Reserves one credit from the key owner's account, stores the file, enqueues @@ -48,20 +60,15 @@ async def analyze_document( # 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}, - ) + key_repo = ApiKeyRepository(session) + await key_repo.record_request(auth.api_key_id, uuid.UUID(str(result.document_id))) + await key_repo.bump_monthly_used(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"], + document_id=result.document_id, error=str(exc), ) # Usage logging is best-effort; do not fail the upload. @@ -69,12 +76,14 @@ async def analyze_document( return result -@router.get("/api/v1/b2b/reports/{document_id}") +@router.get( + "/api/v1/b2b/reports/{document_id}", response_model=ReportResponse | ReportInProgressResponse +) async def get_b2b_report( auth: ApiKeyAuthDep, session: AsyncSessionDep, document_id: uuid.UUID, -) -> dict[str, object]: +) -> ReportResponse | ReportInProgressResponse: """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: @@ -82,83 +91,58 @@ async def get_b2b_report( doc_status = row["status"] if doc_status != "done": - return { - "document_id": str(document_id), - "status": doc_status, - "stage": row["stage"], - } + return ReportInProgressResponse( + 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"], - } + return ReportResponse( + 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") +@router.get("/api/v1/b2b/usage", response_model=B2BUsageResponse) async def get_usage( auth: ApiKeyAuthDep, session: AsyncSessionDep, -) -> dict[str, object]: +) -> B2BUsageResponse: """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 + repo = ApiKeyRepository(session) + key = await repo.get_by_id(auth.api_key_id) + assert key 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()) + _, monthly_used = await repo.get_monthly_quota_state(auth.api_key_id) - 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, - } + return B2BUsageResponse( + api_key_id=str(auth.api_key_id), + rate_limit_rps=auth.rate_limit_rps, + monthly_quota=key.monthly_quota, + monthly_used=monthly_used, + requests_this_month=await repo.count_requests_this_month(auth.api_key_id), + resets_at=key.resets_at.isoformat() if key.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) +@router.post( + "/api/v1/b2b/keys", response_model=ApiKeyCreatedResponse, status_code=status.HTTP_201_CREATED +) async def create_api_key( session: AsyncSessionDep, user: CurrentUserDep, body: CreateApiKeyRequest, -) -> dict[str, object]: +) -> ApiKeyCreatedResponse: """Create a new B2B API key for the authenticated user. The raw key is returned **only once**; afterwards only its hash is stored. @@ -171,124 +155,89 @@ async def create_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, - }, + key = await ApiKeyRepository(session).create( + user_id=user.user_id, + name=body.name, + key_hash=key_hash, + rate_limit_rps=rate_limit, + monthly_quota=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, - } + return ApiKeyCreatedResponse( + api_key=raw_key, + id=key.id, + name=key.name, + rate_limit_rps=key.rate_limit_rps, + monthly_quota=key.monthly_quota, + monthly_used=key.monthly_used, + revoked=key.revoked, + created_at=key.created_at.isoformat() if key.created_at else None, + ) -@router.get("/api/v1/b2b/keys") +@router.get("/api/v1/b2b/keys", response_model=list[ApiKeyResponse]) async def list_api_keys( session: AsyncSessionDep, user: CurrentUserDep, -) -> list[dict[str, object]]: +) -> list[ApiKeyResponse]: """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}, - ) + keys = await ApiKeyRepository(session).list_for_user(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() + ApiKeyResponse( + id=key.id, + name=key.name, + rate_limit_rps=key.rate_limit_rps, + monthly_quota=key.monthly_quota, + monthly_used=key.monthly_used, + revoked=key.revoked, + created_at=key.created_at.isoformat() if key.created_at else None, + ) + for key in keys ] -@router.post("/api/v1/b2b/keys/{key_id}/revoke") +@router.post("/api/v1/b2b/keys/{key_id}/revoke", response_model=RevokeApiKeyResponse) async def revoke_api_key( session: AsyncSessionDep, user: CurrentUserDep, key_id: uuid.UUID, -) -> dict[str, object]: +) -> RevokeApiKeyResponse: """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: + key = await ApiKeyRepository(session).get_by_id(key_id) + if key is None or key.user_id != user.user_id or key.revoked: raise HTTPException(status_code=404, detail="key not found or already revoked") - return {"id": str(key_id), "revoked": True} + await ApiKeyRepository(session).revoke(key_id) + await session.commit() + return RevokeApiKeyResponse(id=str(key_id), revoked=True) -@router.get("/api/v1/b2b/keys/{key_id}/usage") +@router.get("/api/v1/b2b/keys/{key_id}/usage", response_model=ApiKeyUsageDetailResponse) async def get_key_usage( session: AsyncSessionDep, user: CurrentUserDep, key_id: uuid.UUID, -) -> dict[str, object]: +) -> ApiKeyUsageDetailResponse: """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: + repo = ApiKeyRepository(session) + key = await repo.get_by_id(key_id) + if key is None or key.user_id != user.user_id: 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}, - ) + monthly_result = await repo.get_usage_by_key(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() + return ApiKeyUsageDetailResponse( + key_id=str(key_id), + name=key.name, + rate_limit_rps=key.rate_limit_rps, + monthly_quota=key.monthly_quota, + monthly_used=key.monthly_used, + resets_at=key.resets_at.isoformat() if key.resets_at else None, + monthly_requests=[ + {"month": req.created_at.isoformat() if req.created_at else None, "requests": 1} + for req in monthly_result ], - } + ) diff --git a/src/contract_check/api/routes/documents.py b/src/contract_check/api/routes/documents.py index 687c430..6791576 100644 --- a/src/contract_check/api/routes/documents.py +++ b/src/contract_check/api/routes/documents.py @@ -8,19 +8,20 @@ from fastapi import APIRouter, File, UploadFile, status from ...core.logging import get_logger from ..deps import AsyncSessionDep, CurrentUserDep, PublisherDep, StorageDep +from ..schemas import DocumentUploadResponse from ..services import upload_and_enqueue log = get_logger(__name__) -router = APIRouter(tags=["documents"]) +router = APIRouter(prefix="/api/v1/documents", tags=["documents"]) -@router.post("/api/v1/documents", status_code=status.HTTP_202_ACCEPTED) +@router.post("", response_model=DocumentUploadResponse, status_code=status.HTTP_202_ACCEPTED) async def upload_document( session: AsyncSessionDep, storage: StorageDep, publisher: PublisherDep, user: CurrentUserDep, file: Annotated[UploadFile, File()], -) -> dict[str, object]: +) -> DocumentUploadResponse: 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 index 0b57e28..99cee04 100644 --- a/src/contract_check/api/routes/health.py +++ b/src/contract_check/api/routes/health.py @@ -18,6 +18,7 @@ async def healthz() -> dict[str, str]: @router.get("/readyz") async def readyz(session: AsyncSessionDep) -> dict[str, str]: try: + # Deliberate raw SQL: lightweight connection/statement round-trip ping. await session.execute(text("SELECT 1")) except Exception as exc: return {"status": "not_ready", "reason": f"db: {exc}"} diff --git a/src/contract_check/api/routes/me.py b/src/contract_check/api/routes/me.py index fd8f9ea..b1aaa04 100644 --- a/src/contract_check/api/routes/me.py +++ b/src/contract_check/api/routes/me.py @@ -3,9 +3,8 @@ from __future__ import annotations from fastapi import APIRouter, status -from pydantic import BaseModel, Field -from sqlalchemy import text +from ...core.db.repositories import DocumentRepository from ..deps import ( AsyncSessionDep, CurrentUserDep, @@ -13,30 +12,19 @@ from ..deps import ( get_credits, set_user_password, ) +from ..schemas import ( + BindTelegramRequest, + BindTelegramResponse, +) +from ..schemas import MeCreditsResponse as MeResponse +from ..schemas import ( + SetPasswordRequest, +) -router = APIRouter(tags=["me"]) +router = APIRouter(prefix="/api/v1/me", tags=["me"]) -class MeResponse(BaseModel): - telegram_id: int | None = None - credits_left: int - email: str | None = None - - -class BindTelegramRequest(BaseModel): - telegram_id: int = Field(..., gt=0, description="Verified Telegram user id") - - -class BindTelegramResponse(BaseModel): - ok: bool = True - telegram_id: int - - -class SetPasswordRequest(BaseModel): - password: str = Field(..., min_length=8, max_length=128) - - -@router.get("/api/v1/me", response_model=MeResponse) +@router.get("", response_model=MeResponse) async def me( session: AsyncSessionDep, user: CurrentUserDep, @@ -48,7 +36,7 @@ async def me( ) -@router.get("/api/v1/me/documents") +@router.get("/documents") async def list_my_documents( session: AsyncSessionDep, user: CurrentUserDep, @@ -60,32 +48,22 @@ async def list_my_documents( if limit > 100: limit = 100 - result = await session.execute( - text( - "SELECT d.id, d.filename, d.status, d.stage, d.created_at " - "FROM documents d " - "WHERE d.user_id = :u " - "ORDER BY d.created_at DESC " - "LIMIT :limit" - ), - {"u": user.user_id, "limit": limit}, - ) - rows = result.all() + documents = await DocumentRepository(session).list_for_user(user.user_id, limit=limit, offset=0) return { "documents": [ { - "document_id": str(row[0]), - "filename": row[1], - "status": row[2], - "stage": row[3], - "created_at": row[4].isoformat() if row[4] else None, + "document_id": str(doc.id), + "filename": doc.filename, + "status": doc.status, + "stage": doc.stage, + "created_at": doc.created_at.isoformat() if doc.created_at else None, } - for row in rows + for doc in documents ] } -@router.post("/api/v1/me/telegram", response_model=BindTelegramResponse) +@router.post("/telegram", response_model=BindTelegramResponse) async def bind_telegram( session: AsyncSessionDep, user: CurrentUserDep, @@ -96,7 +74,7 @@ async def bind_telegram( return BindTelegramResponse(ok=True, telegram_id=body.telegram_id) -@router.post("/api/v1/me/password", status_code=status.HTTP_200_OK) +@router.post("/password", status_code=status.HTTP_200_OK) async def set_password( session: AsyncSessionDep, user: CurrentUserDep, diff --git a/src/contract_check/api/routes/reports.py b/src/contract_check/api/routes/reports.py index d858bac..9433d1f 100644 --- a/src/contract_check/api/routes/reports.py +++ b/src/contract_check/api/routes/reports.py @@ -7,36 +7,37 @@ from uuid import UUID from fastapi import APIRouter, HTTPException from ..deps import AsyncSessionDep, CurrentUserDep, fetch_document_status_for_user +from ..schemas import ReportInProgressResponse, ReportResponse -router = APIRouter(tags=["reports"]) +router = APIRouter(prefix="/api/v1/reports", tags=["reports"]) -@router.get("/api/v1/reports/{document_id}") +@router.get("/{document_id}", response_model=ReportResponse | ReportInProgressResponse) async def get_report( session: AsyncSessionDep, document_id: UUID, user: CurrentUserDep, -) -> dict[str, object]: +) -> ReportResponse | ReportInProgressResponse: row = await fetch_document_status_for_user(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"], - } + doc_status = row["status"] + if doc_status != "done": + return ReportInProgressResponse( + document_id=str(document_id), + status=doc_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"], - } + return ReportResponse( + 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"], + ) diff --git a/src/contract_check/api/schemas/__init__.py b/src/contract_check/api/schemas/__init__.py new file mode 100644 index 0000000..02592d3 --- /dev/null +++ b/src/contract_check/api/schemas/__init__.py @@ -0,0 +1,108 @@ +"""API Pydantic schemas (request/response DTOs) for route modules. + +This package is the single source of truth for JSON shapes crossing the HTTP +boundary. Route modules import from here; no Pydantic model definitions should +live in `api/routes/*` except tiny inline query/path params. +""" + +from __future__ import annotations + +from .auth import ( + AuthResponse, + ForgotPasswordRequest, + LoginRequest, + LogoutRequest, + MagicLinkRequest, + MagicLinkResponse, + MagicLinkVerifyRequest, + MeResponse, + OkResponse, + PasskeyAuthenticateFinishRequest, + PasskeyAuthenticationCredentialData, + PasskeyAuthenticationCredentialPayload, + PasskeyAuthenticationOptions, + PasskeyCredentialPublic, + PasskeyRegisterFinishRequest, + PasskeyRegisterStartRequest, + PasskeyRegistrationCredentialData, + PasskeyRegistrationCredentialPayload, + PasskeyRegistrationFinishResponse, + PasskeyRegistrationOptions, + RegisterRequest, + ResetPasswordRequest, + SingleTokenAuthResponse, + TelegramBotAuthRequest, + TelegramMiniAppAuthRequest, + TelegramProfile, + TelegramWebAuthRequest, + TokenIntrospectResponse, + TokenPairResponse, + WebUserPublic, +) +from .b2b import ( + ApiKeyCreatedResponse, + ApiKeyResponse, + ApiKeyUsageDetailResponse, + B2BUsageResponse, + CreateApiKeyRequest, + RevokeApiKeyResponse, +) +from .common import DocumentUploadResponse, ReportInProgressResponse, ReportResponse +from .documents import DocumentListResponse # noqa: F401 +from .me import ( + BindTelegramRequest, + BindTelegramResponse, + MeCreditsResponse, + SetPasswordRequest, +) + +__all__ = [ + # auth + "TelegramProfile", + "TelegramBotAuthRequest", + "TelegramWebAuthRequest", + "TelegramMiniAppAuthRequest", + "AuthResponse", + "TokenIntrospectResponse", + "WebUserPublic", + "MeResponse", + "RegisterRequest", + "LoginRequest", + "TokenPairResponse", + "LogoutRequest", + "ForgotPasswordRequest", + "ResetPasswordRequest", + "OkResponse", + "PasskeyRegistrationOptions", + "PasskeyAuthenticationOptions", + "PasskeyCredentialPublic", + "PasskeyRegistrationFinishResponse", + "SingleTokenAuthResponse", + "MagicLinkResponse", + "PasskeyRegisterStartRequest", + "PasskeyRegistrationCredentialData", + "PasskeyRegistrationCredentialPayload", + "PasskeyRegisterFinishRequest", + "PasskeyAuthenticationCredentialData", + "PasskeyAuthenticationCredentialPayload", + "PasskeyAuthenticateFinishRequest", + "MagicLinkRequest", + "MagicLinkVerifyRequest", + # me + "MeCreditsResponse", + "BindTelegramRequest", + "BindTelegramResponse", + "SetPasswordRequest", + # documents / reports / common + "DocumentUploadResponse", + "DocumentListResponse", + "ReportInProgressResponse", + "ReportResponse", + # b2b + "CreateApiKeyRequest", + "ApiKeyResponse", + "ApiKeyCreatedResponse", + "B2BUsageResponse", + "ApiKeyUsageDetailResponse", + "RevokeApiKeyResponse", +] diff --git a/src/contract_check/api/schemas/auth.py b/src/contract_check/api/schemas/auth.py new file mode 100644 index 0000000..662a5cd --- /dev/null +++ b/src/contract_check/api/schemas/auth.py @@ -0,0 +1,250 @@ +"""Authentication request/response schemas.""" + +from __future__ import annotations + +import datetime as dt +import uuid + +from pydantic import BaseModel, EmailStr, Field + + +class TelegramProfile(BaseModel): + username: str | None = None + first_name: str | None = None + last_name: str | None = None + language_code: str | None = None + + +class TelegramBotAuthRequest(BaseModel): + telegram_id: int = Field(..., gt=0, description="Verified Telegram user id from aiogram") + profile: TelegramProfile | None = None + + +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 + + +class WebUserPublic(BaseModel): + """User profile subset safe to return to the webUI.""" + + id: str + email: str | None = None + name: str | None = None + telegram_id: int | None = None + credits_left: int + is_active: bool + created_at: dt.datetime + + +class MeResponse(TokenIntrospectResponse): + """Extends the introspection shape with web-user fields (additive).""" + + email: str | None = None + name: str | None = None + credits_left: int = 0 + is_active: bool = True + created_at: dt.datetime | None = None + + +class RegisterRequest(BaseModel): + email: EmailStr + name: str = Field(..., min_length=1, max_length=128) + password: str = Field(..., min_length=8, max_length=128) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=1, max_length=128) + + +class TokenPairResponse(BaseModel): + """JWT pair returned by register/login.""" + + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int # access TTL in seconds + user: WebUserPublic + + +class LogoutRequest(BaseModel): + refresh_token: str = Field(..., min_length=1) + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + token: str = Field(..., min_length=1, max_length=256) + password: str = Field(..., min_length=8, max_length=128) + + +class OkResponse(BaseModel): + """Generic `{ok: true, ...}` payload for state-mutating auth endpoints.""" + + ok: bool = True + detail: str | None = None + + +class PasskeyRpInfo(BaseModel): + id: str + name: str + origin: str + + +class PasskeyUserInfo(BaseModel): + id: str + name: str + displayName: str + + +class PubKeyCredParam(BaseModel): + alg: int + type: str + + +class CredDescriptor(BaseModel): + id: str + type: str + + +class AuthenticatorSelectionInfo(BaseModel): + residentKey: str + userVerification: str + + +class PasskeyRegistrationOptions(BaseModel): + """PublicKeyCredentialCreationOptions — matches the frontend zod schema.""" + + challenge: str + rp: PasskeyRpInfo + user: PasskeyUserInfo + pubKeyCredParams: list[PubKeyCredParam] + timeout: int + attestation: str + excludeCredentials: list[CredDescriptor] + authenticatorSelection: AuthenticatorSelectionInfo + + +class PasskeyAuthenticationOptions(BaseModel): + """PublicKeyCredentialRequestOptions — matches the frontend zod schema.""" + + challenge: str + timeout: int + rpId: str + allowCredentials: list[CredDescriptor] + userVerification: str + + +class PasskeyCredentialPublic(BaseModel): + """Stored credential — matches passkeyCredentialSchema on the frontend.""" + + id: str + credentialID: str + credentialPublicKey: str + counter: int + userId: str + deviceName: str + createdAt: dt.datetime + + +class PasskeyRegistrationFinishResponse(BaseModel): + verified: bool + credentialId: str + + +class SingleTokenAuthResponse(BaseModel): + """Shared by passkey-auth finish and magic-link verify (frontend schemas).""" + + access_token: str + token_type: str = "bearer" + expires_in: int + user_id: uuid.UUID + email: str + + +class MagicLinkResponse(BaseModel): + """Matches magicLinkResponseSchema on the frontend.""" + + success: bool = True + message: str + expires_in: int # link TTL in seconds + + +class PasskeyRegisterStartRequest(BaseModel): + device_name: str | None = Field(default=None, max_length=128) + + +class PasskeyRegistrationCredentialData(BaseModel): + """`navigator.credentials.create()` result, base64url-encoded.""" + + clientDataJSON: str + attestationObject: str + transports: list[str] | None = None + + +class PasskeyRegistrationCredentialPayload(BaseModel): + id: str + rawId: str + type: str + response: PasskeyRegistrationCredentialData + + +class PasskeyRegisterFinishRequest(BaseModel): + credential: PasskeyRegistrationCredentialPayload + device_name: str | None = Field(default=None, max_length=128) + + +class PasskeyAuthenticationCredentialData(BaseModel): + """`navigator.credentials.get()` result, base64url-encoded.""" + + clientDataJSON: str + authenticatorData: str + signature: str + userHandle: str | None = None + + +class PasskeyAuthenticationCredentialPayload(BaseModel): + id: str + rawId: str + type: str + response: PasskeyAuthenticationCredentialData + + +class PasskeyAuthenticateFinishRequest(BaseModel): + challenge: str = Field(..., min_length=16, max_length=512) + credential: PasskeyAuthenticationCredentialPayload + + +class MagicLinkRequest(BaseModel): + email: EmailStr + + +class MagicLinkVerifyRequest(BaseModel): + token: str = Field(..., min_length=1, max_length=256) diff --git a/src/contract_check/api/schemas/b2b.py b/src/contract_check/api/schemas/b2b.py new file mode 100644 index 0000000..7e7d0c2 --- /dev/null +++ b/src/contract_check/api/schemas/b2b.py @@ -0,0 +1,59 @@ +"""B2B API request/response schemas.""" + +from __future__ import annotations + +import uuid +from typing import Any + +from pydantic import BaseModel + + +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 | None + + +class ApiKeyCreatedResponse(ApiKeyResponse): + """Response when creating a key: raw secret is returned exactly once.""" + + api_key: str + + +class B2BUsageResponse(BaseModel): + api_key_id: str + rate_limit_rps: int + monthly_quota: int | None + monthly_used: int + requests_this_month: int + resets_at: str | None + + +class ApiKeyMonthlyRequest(BaseModel): + month: str | None + requests: int + + +class ApiKeyUsageDetailResponse(BaseModel): + key_id: str + name: str + rate_limit_rps: int + monthly_quota: int | None + monthly_used: int + resets_at: str | None + monthly_requests: list[dict[str, Any]] + + +class RevokeApiKeyResponse(BaseModel): + id: str + revoked: bool diff --git a/src/contract_check/api/schemas/common.py b/src/contract_check/api/schemas/common.py new file mode 100644 index 0000000..729489e --- /dev/null +++ b/src/contract_check/api/schemas/common.py @@ -0,0 +1,51 @@ +"""Shared response schemas used across multiple route modules.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class DocumentUploadResponse(BaseModel): + """202 response issued by document upload and B2B analyze endpoints.""" + + document_id: str + correlation_id: str + credits_left: int + + +class ReportInProgressResponse(BaseModel): + """Polling response while a document is still being processed.""" + + document_id: str + status: str + stage: Any | None = None + + +class ReportResponse(BaseModel): + """Completed analysis report payload.""" + + document_id: str + status: Any = "done" + filename: Any | None = None + markdown: Any | None = None + findings: Any | None = Field(default=None, alias="findings") + model_used: Any | None = None + prompt_tokens: Any | None = None + eval_tokens: Any | None = None + latency_ms: Any | None = None + + model_config = ConfigDict(populate_by_name=True) + + +class DocumentListItem(BaseModel): + document_id: str + filename: str | None = None + status: str | None = None + stage: str | None = None + created_at: str | None = None + + +class DocumentListResponse(BaseModel): + documents: list[DocumentListItem] diff --git a/src/contract_check/api/schemas/documents.py b/src/contract_check/api/schemas/documents.py new file mode 100644 index 0000000..98c4583 --- /dev/null +++ b/src/contract_check/api/schemas/documents.py @@ -0,0 +1,11 @@ +"""`/documents` route response schemas.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from .common import DocumentListItem + + +class DocumentListResponse(BaseModel): + documents: list[DocumentListItem] diff --git a/src/contract_check/api/schemas/me.py b/src/contract_check/api/schemas/me.py new file mode 100644 index 0000000..3062672 --- /dev/null +++ b/src/contract_check/api/schemas/me.py @@ -0,0 +1,24 @@ +"""`/me` route request/response schemas.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class MeCreditsResponse(BaseModel): + telegram_id: int | None = None + credits_left: int + email: str | None = None + + +class BindTelegramRequest(BaseModel): + telegram_id: int = Field(..., gt=0, description="Verified Telegram user id") + + +class BindTelegramResponse(BaseModel): + ok: bool = True + telegram_id: int + + +class SetPasswordRequest(BaseModel): + password: str = Field(..., min_length=8, max_length=128) diff --git a/src/contract_check/api/schemas/reports.py b/src/contract_check/api/schemas/reports.py new file mode 100644 index 0000000..5096262 --- /dev/null +++ b/src/contract_check/api/schemas/reports.py @@ -0,0 +1,12 @@ +"""`/reports` route response schemas. + +The actual response models live in `common.py` because they are also used by +B2B reporting endpoints; this module exists only as a dedicated import target +for `api/routes/reports.py`. +""" + +from __future__ import annotations + +from .common import ReportInProgressResponse, ReportResponse + +__all__ = ["ReportInProgressResponse", "ReportResponse"] diff --git a/src/contract_check/api/services.py b/src/contract_check/api/services.py index 5e58931..0ecdacb 100644 --- a/src/contract_check/api/services.py +++ b/src/contract_check/api/services.py @@ -6,15 +6,17 @@ 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.db.repositories import DocumentRepository, JobRepository +from ..core.db.repositories.credits import CreditsRepository +from ..core.extraction.formats import SUPPORTED_SUFFIXES 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 +from .schemas import DocumentUploadResponse if TYPE_CHECKING: from fastapi import UploadFile @@ -39,7 +41,7 @@ async def upload_and_enqueue( publisher: Publisher, user_id: uuid.UUID, file: UploadFile, -) -> dict[str, object]: +) -> DocumentUploadResponse: """Reserve credit, store file in MinIO, create document/job rows, publish to queue. Returns 202 payload: {document_id, correlation_id, credits_left}. @@ -56,6 +58,10 @@ async def upload_and_enqueue( detail=f"unsupported format {suffix!r}; supported: {sorted(SUPPORTED_SUFFIXES)}", ) + credits_repo = CreditsRepository(session) + doc_repo = DocumentRepository(session) + job_repo = JobRepository(session) + if not await reserve_credit(session, user_id): raise HTTPException(status_code=402, detail="no credits available") await session.commit() @@ -75,42 +81,30 @@ async def upload_and_enqueue( 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 credits_repo.adjust(user_id, 1) 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 doc_repo.create( + document_id=document_id, + user_id=user_id, + s3_key=s3_key, + filename=file.filename or "document", + mime=content_type, + bytes_=len(data), + status="queued", ) - 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 job_repo.create( + document_id=document_id, + correlation_id=uuid.UUID(correlation_id), + queue="extract", + status="pending", ) 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 credits_repo.adjust(user_id, 1) await session.commit() raise HTTPException(status_code=500, detail="failed to enqueue document") from exc @@ -127,23 +121,14 @@ async def upload_and_enqueue( 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 credits_repo.adjust(user_id, 1) + await doc_repo.mark_failed(document_id, stage="publish_failed") 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}, + credits_left = await credits_repo.get_balance(user_id) + return DocumentUploadResponse( + document_id=str(document_id), + correlation_id=str(correlation_id), + credits_left=credits_left, ) - return { - "document_id": str(document_id), - "correlation_id": str(correlation_id), - "credits_left": int(credits.scalar_one()), - } diff --git a/src/contract_check/bot/handlers.py b/src/contract_check/bot/handlers.py index 0112771..a586322 100644 --- a/src/contract_check/bot/handlers.py +++ b/src/contract_check/bot/handlers.py @@ -21,6 +21,7 @@ from aiogram import Bot, F, Router from aiogram.filters import Command from aiogram.types import BufferedInputFile, Document, Message, User +from ..core.extraction.formats import SUPPORTED_SUFFIXES as _SUPPORTED_SUFFIXES from ..core.logging import get_logger, new_correlation_id from ..core.rate_limit import RateLimiter from .client import ( @@ -36,8 +37,6 @@ 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", diff --git a/src/contract_check/core/analysis/extractor.py b/src/contract_check/core/analysis/extractor.py index fe18b7b..874ac7c 100644 --- a/src/contract_check/core/analysis/extractor.py +++ b/src/contract_check/core/analysis/extractor.py @@ -10,12 +10,11 @@ from __future__ import annotations from pathlib import Path +from ..extraction.formats import SUPPORTED_SUFFIXES from ..logging import get_logger log = get_logger(__name__) -SUPPORTED_SUFFIXES = {".pdf", ".docx"} - class ExtractionError(Exception): """Файл пуст, бит или формат не поддерживается.""" diff --git a/src/contract_check/core/api_keys.py b/src/contract_check/core/api_keys.py index 080ae36..9b367d9 100644 --- a/src/contract_check/core/api_keys.py +++ b/src/contract_check/core/api_keys.py @@ -6,7 +6,6 @@ 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 @@ -16,11 +15,5 @@ def generate_api_key() -> str: def hash_api_key(key: str) -> str: - """SHA-256 hex digest of an API key (store this, never the raw key).""" + """SHA-256 hex digest of an API key (store this, never the raw token).""" 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/config.py b/src/contract_check/core/config.py index 3f95873..0be6ff0 100644 --- a/src/contract_check/core/config.py +++ b/src/contract_check/core/config.py @@ -199,12 +199,13 @@ class Settings(BaseSettings): chunk_size_chars: int = 10000 # --- prescreen --- - prescreen_enabled: bool = False - prescreen_confidence_threshold: float = 0.65 - prescreen_high_value_threshold: float = 500_000.0 + # Defaults mirror .env.example (the documented production intent). + prescreen_enabled: bool = True + prescreen_confidence_threshold: float = 0.75 + prescreen_high_value_threshold: float = 100_000.0 prescreen_auto_approve: bool = False - prescreen_llm_fallback_enabled: bool = True - prescreen_llm_fallback_threshold: float = 0.55 + prescreen_llm_fallback_enabled: bool = False + prescreen_llm_fallback_threshold: float = 0.75 prescreen_llm_max_chars: int = 20_000 @property diff --git a/src/contract_check/core/credits.py b/src/contract_check/core/credits.py index 292e965..f93871e 100644 --- a/src/contract_check/core/credits.py +++ b/src/contract_check/core/credits.py @@ -1,5 +1,9 @@ """Credits & refund policy — billing invariants. +Thin public wrappers around `CreditsRepository`. The actual SQL lives in one +place (`core.db.repositories.credits`) so that issue 006 can migrate callers +without duplicating the atomic statements. + 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). @@ -14,40 +18,26 @@ from __future__ import annotations import uuid -from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from .db.enums import FailureClass, RefundPolicyLike +from .db.repositories import CreditsRepository +from .db.repositories.credits import ( + NON_REFUNDABLE_INFRA_ONLY, + should_refund, +) -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 +__all__ = [ + "reserve_credit", + "refund_credit", + "should_refund", + "NON_REFUNDABLE_INFRA_ONLY", +] 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 + """Atomically decrement credits_left by 1. Returns False if none available.""" + return await CreditsRepository(session).reserve(user_id) async def refund_credit( @@ -56,29 +46,5 @@ async def refund_credit( 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 + """Refund one credit for a failed document, once. Returns False if skipped.""" + return await CreditsRepository(session).refund(document_id, failure_class, policy) diff --git a/src/contract_check/core/db/enums.py b/src/contract_check/core/db/enums.py index 30187a1..dabf54b 100644 --- a/src/contract_check/core/db/enums.py +++ b/src/contract_check/core/db/enums.py @@ -9,14 +9,25 @@ from __future__ import annotations from typing import Literal # documents.status -DocStatus = Literal["queued", "extracting", "ocr", "analyzing", "done", "failed"] -DOC_STATUSES: tuple[str, ...] = ( +DocStatus = Literal[ "queued", "extracting", + "prescreening", "ocr", "analyzing", "done", "failed", + "manual_review", +] +DOC_STATUSES: tuple[str, ...] = ( + "queued", + "extracting", + "prescreening", + "ocr", + "analyzing", + "done", + "failed", + "manual_review", ) DOC_TERMINAL: tuple[str, ...] = ("done", "failed") @@ -25,8 +36,12 @@ 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") +QueueName = Literal["extract", "prescreen", "analyze"] +QUEUE_NAMES: tuple[str, ...] = ("extract", "prescreen", "analyze") + +# prescreen_results.routing_decision +RoutingDecision = Literal["auto_approve", "manual_review", "deep_analysis"] +ROUTING_DECISIONS: tuple[str, ...] = ("auto_approve", "manual_review", "deep_analysis") # service_tokens.adapter AdapterName = Literal["bot", "web", "cli"] diff --git a/src/contract_check/core/db/models.py b/src/contract_check/core/db/models.py index f1881c6..8f0a661 100644 --- a/src/contract_check/core/db/models.py +++ b/src/contract_check/core/db/models.py @@ -1,24 +1,27 @@ -"""SQLAlchemy 2 declarative models — the 7 production tables. +"""SQLAlchemy 2 declarative models — the 10 production tables. Tables: users, documents, reports, jobs, service_tokens, invoices (stub), -passkey_credentials. See docs/ARCHITECTURE.md §7. UUIDs default to -gen_random_uuid() server-side (built into Postgres 13+, no extension needed -for pg16). +api_keys, api_key_requests, passkey_credentials, prescreen_results. +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 decimal import Decimal from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, + Date, DateTime, ForeignKey, Index, Integer, + Numeric, String, Text, UniqueConstraint, @@ -59,11 +62,21 @@ class User(Base): role: Mapped[str] = mapped_column( String, nullable=False, default="user", server_default=text("'user'") ) - telegram_profile_json: Mapped[str | None] = mapped_column(Text) - telegram_verified: Mapped[bool] = mapped_column( - Boolean, nullable=False, default=True, server_default=text("true") + telegram_profile_json: Mapped[str | None] = mapped_column( + Text, + comment="Snapshot of Telegram first_name/last_name/username/language_code", + ) + telegram_verified: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=True, + server_default=text("true"), + comment="Telegram user passed profile guard; false = admin review", + ) + telegram_bound_at: Mapped[dt.datetime | None] = mapped_column( + DateTime(timezone=True), + comment="When telegram_id was first set (auto-created or bound)", ) - telegram_bound_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) @@ -77,6 +90,9 @@ class User(Base): name="users_identity_present", ), CheckConstraint("role IN ('user', 'admin')", name="users_role_check"), + Index("users_role_idx", "role"), + Index("users_telegram_verified_idx", "telegram_verified"), + Index("users_telegram_bound_idx", "telegram_bound_at"), ) documents: Mapped[list[Document]] = relationship( @@ -168,16 +184,20 @@ class Document(Base): back_populates="document", cascade="all, delete-orphan", uselist=False ) jobs: Mapped[list[Job]] = relationship(back_populates="document", cascade="all, delete-orphan") + prescreen_results: Mapped[list[PrescreenResult]] = 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')", + "status IN ('queued','extracting','prescreening','ocr','analyzing'" + ",'done','failed','manual_review')", name="documents_status_check", ), - Index("documents_user_created_idx", "user_id", "created_at"), + Index("documents_user_created_idx", "user_id", text("created_at DESC")), Index("documents_status_idx", "status"), ) @@ -202,6 +222,15 @@ class Report(Base): 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) + prescreen_result_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("prescreen_results.id", ondelete="SET NULL"), + nullable=True, + ) + prescreen_meta: Mapped[dict[str, object] | None] = mapped_column( + JSONB, + comment="Serialized PrescreenCompleted metadata used by the analyzer", + ) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) @@ -209,6 +238,74 @@ class Report(Base): document: Mapped[Document] = relationship(back_populates="report") +class PrescreenResult(Base): + """Deterministic regex-extraction result + routing decision (migration 0006). + + Written by worker_prescreen between extract and analyze; feeds the + analyzer via ``reports.prescreen_meta`` when routed to deep_analysis. + """ + + __tablename__ = "prescreen_results" + + 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, + index=True, + ) + correlation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + contract_type: Mapped[str | None] = mapped_column(String(64)) + party_a: Mapped[str | None] = mapped_column(Text) + party_b: Mapped[str | None] = mapped_column(Text) + total_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2)) + currency: Mapped[str | None] = mapped_column(String(8)) + start_date: Mapped[dt.date | None] = mapped_column(Date) + end_date: Mapped[dt.date | None] = mapped_column(Date) + has_penalty_clause: Mapped[bool | None] = mapped_column(Boolean) + has_termination_clause: Mapped[bool | None] = mapped_column(Boolean) + has_arbitration: Mapped[bool | None] = mapped_column(Boolean) + confidence_score: Mapped[Decimal | None] = mapped_column( + Numeric(4, 3), comment="Field-coverage ratio 0.000-1.000" + ) + routing_decision: Mapped[str] = mapped_column( + String(32), nullable=False, server_default=text("'manual_review'") + ) + prescreened_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + processing_ms: Mapped[int | None] = mapped_column(Integer) + extractor_version: Mapped[str] = mapped_column( + String(32), nullable=False, server_default=text("'regex-v1'") + ) + auto_summary: Mapped[str | None] = mapped_column(Text) + auto_findings: Mapped[list[object]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'") + ) + error_message: Mapped[str | None] = mapped_column(Text) + retry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + + document: Mapped[Document] = relationship(back_populates="prescreen_results") + + __table_args__ = ( + CheckConstraint( + "routing_decision IN ('auto_approve','manual_review','deep_analysis')", + name="prescreen_results_routing_decision_check", + ), + CheckConstraint( + "confidence_score IS NULL OR (confidence_score >= 0 AND confidence_score <= 1)", + name="prescreen_results_confidence_check", + ), + CheckConstraint("retry_count >= 0", name="prescreen_results_retry_count_nonneg"), + Index("prescreen_results_routing_idx", "routing_decision"), + Index("prescreen_results_confidence_idx", "confidence_score"), + ) + + class Job(Base): __tablename__ = "jobs" @@ -247,11 +344,12 @@ class Job(Base): document: Mapped[Document] = relationship(back_populates="jobs") __table_args__ = ( - CheckConstraint("queue IN ('extract','analyze')", name="jobs_queue_check"), + CheckConstraint("queue IN ('extract','prescreen','analyze')", name="jobs_queue_check"), CheckConstraint( "status IN ('pending','running','retrying','dlq','done')", name="jobs_status_check", ), + UniqueConstraint("document_id", "queue", name="jobs_document_queue_unique"), Index("jobs_correlation_idx", "correlation_id"), Index("jobs_document_idx", "document_id"), ) @@ -397,5 +495,5 @@ class Invoice(Base): "status IN ('draft','pending','succeeded','cancelled','refunded')", name="invoices_status_check", ), - Index("invoices_user_idx", "user_id", "created_at"), + Index("invoices_user_idx", "user_id", text("created_at DESC")), ) diff --git a/src/contract_check/core/db/repositories/__init__.py b/src/contract_check/core/db/repositories/__init__.py new file mode 100644 index 0000000..d387b3e --- /dev/null +++ b/src/contract_check/core/db/repositories/__init__.py @@ -0,0 +1,32 @@ +"""Lightweight repository layer over SQLAlchemy 2 models. + +Each repository owns one table/cluster of tables and receives an open +``AsyncSession`` in its constructor. Callers retain transaction control unless a +method is explicitly documented as committing (e.g. credit operations that must +run in their own statement). + +This package deliberately avoids generic base classes, service-locator magic, +and EXPLAIN logging. See .scratch/refactor-base-api-alignment/issues/005. +""" + +from .api_keys import ApiKeyRepository +from .credits import CreditsRepository +from .documents import DocumentRepository +from .jobs import JobRepository +from .passkeys import PasskeyRepository +from .prescreen_results import PrescreenResultRepository +from .reports import ReportRepository +from .service_tokens import ServiceTokenRepository +from .users import UserRepository + +__all__ = [ + "ApiKeyRepository", + "CreditsRepository", + "DocumentRepository", + "JobRepository", + "PasskeyRepository", + "PrescreenResultRepository", + "ReportRepository", + "ServiceTokenRepository", + "UserRepository", +] diff --git a/src/contract_check/core/db/repositories/api_keys.py b/src/contract_check/core/db/repositories/api_keys.py new file mode 100644 index 0000000..a42c856 --- /dev/null +++ b/src/contract_check/core/db/repositories/api_keys.py @@ -0,0 +1,220 @@ +"""B2B API key data access. + +Includes the key-lookup + monthly-quota reset + request ledger paths. +""" + +from __future__ import annotations + +import datetime as dt +import uuid +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import ApiKey, ApiKeyRequest + + +@dataclass(frozen=True, slots=True) +class ApiKeyAuth: + """Validated API key metadata.""" + + api_key_id: uuid.UUID + user_id: uuid.UUID + rate_limit_rps: int + + +class ApiKeyRepository: + """All B2B API-key reads/writes.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_hash(self, key_hash: str) -> ApiKey | None: + """Return the full key row by hash.""" + result = await self._session.execute( + text( + "SELECT id, user_id, name, key_hash, rate_limit_rps, monthly_quota, " + "monthly_used, resets_at, revoked, created_at, last_used_at " + "FROM api_keys WHERE key_hash = :h" + ), + {"h": key_hash}, + ) + row = result.first() + if row is None: + return None + return ApiKey( + id=row[0], + user_id=row[1], + name=row[2], + key_hash=row[3], + rate_limit_rps=row[4], + monthly_quota=row[5], + monthly_used=row[6], + resets_at=row[7], + revoked=row[8], + created_at=row[9], + last_used_at=row[10], + ) + + async def maybe_reset_monthly_quota(self, api_key_id: uuid.UUID) -> None: + """Reset the quota window if it has expired.""" + await self._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 get_monthly_quota_state(self, api_key_id: uuid.UUID) -> tuple[int | None, int]: + """Return (monthly_quota, monthly_used) after any auto-reset.""" + await self.maybe_reset_monthly_quota(api_key_id) + result = await self._session.execute( + text("SELECT monthly_quota, monthly_used FROM api_keys WHERE id = :k"), + {"k": api_key_id}, + ) + row = result.first() + if row is None: + return None, 0 + return row[0], row[1] + + async def bump_last_used(self, api_key_id: uuid.UUID) -> None: + await self._session.execute( + text("UPDATE api_keys SET last_used_at = now() WHERE id = :id"), + {"id": api_key_id}, + ) + + async def record_request(self, api_key_id: uuid.UUID, document_id: uuid.UUID) -> None: + await self._session.execute( + text("INSERT INTO api_key_requests (api_key_id, document_id) VALUES (:k, :d)"), + {"k": api_key_id, "d": document_id}, + ) + + async def bump_monthly_used(self, api_key_id: uuid.UUID) -> None: + await self._session.execute( + text("UPDATE api_keys SET monthly_used = monthly_used + 1 WHERE id = :k"), + {"k": api_key_id}, + ) + + async def count_recent_requests(self, api_key_id: uuid.UUID, since: dt.datetime) -> int: + result = await self._session.execute( + text( + "SELECT count(*) FROM api_key_requests " + "WHERE api_key_id = :k AND created_at > :since" + ), + {"k": api_key_id, "since": since}, + ) + return int(result.scalar_one()) + + async def get_by_id(self, api_key_id: uuid.UUID) -> ApiKey | None: + return await self._session.get(ApiKey, api_key_id) + + async def list_for_user(self, user_id: uuid.UUID) -> list[ApiKey]: + result = await self._session.execute( + select(ApiKey).where(ApiKey.user_id == user_id).order_by(ApiKey.created_at.desc()) + ) + return list(result.scalars().all()) + + async def create( + self, + *, + user_id: uuid.UUID, + name: str, + key_hash: str, + rate_limit_rps: int = 3, + monthly_quota: int | None = None, + ) -> ApiKey: + result = await self._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, user_id, name, key_hash, rate_limit_rps, monthly_quota, " + "monthly_used, resets_at, revoked, created_at, last_used_at" + ), + { + "u": user_id, + "n": name, + "h": key_hash, + "r": rate_limit_rps, + "q": monthly_quota, + }, + ) + row = result.first() + assert row is not None + return ApiKey( + id=row[0], + user_id=row[1], + name=row[2], + key_hash=row[3], + rate_limit_rps=row[4], + monthly_quota=row[5], + monthly_used=row[6], + resets_at=row[7], + revoked=row[8], + created_at=row[9], + last_used_at=row[10], + ) + + async def revoke(self, api_key_id: uuid.UUID) -> None: + await self._session.execute( + text("UPDATE api_keys SET revoked = TRUE WHERE id = :k"), + {"k": api_key_id}, + ) + + async def get_usage_by_key( + self, + api_key_id: uuid.UUID, + *, + since: dt.datetime | None = None, + limit: int = 100, + offset: int = 0, + ) -> list[ApiKeyRequest]: + where_parts = ["api_key_id = :k"] + params: dict[str, Any] = {"k": api_key_id, "limit": limit, "offset": offset} + if since is not None: + where_parts.append("created_at > :since") + params["since"] = since + where = " AND ".join(where_parts) + result = await self._session.execute( + text( + f"SELECT id, api_key_id, document_id, created_at FROM api_key_requests " + f"WHERE {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset" + ), + params, + ) + return [ + ApiKeyRequest(id=r[0], api_key_id=r[1], document_id=r[2], created_at=r[3]) + for r in result.all() + ] + + async def count_usage_by_key( + self, + api_key_id: uuid.UUID, + since: dt.datetime | None = None, + ) -> int: + where_parts = ["api_key_id = :k"] + params: dict[str, Any] = {"k": api_key_id} + if since is not None: + where_parts.append("created_at > :since") + params["since"] = since + where = " AND ".join(where_parts) + result = await self._session.execute( + text(f"SELECT count(*) FROM api_key_requests WHERE {where}"), + params, + ) + return int(result.scalar_one()) + + async def count_requests_this_month(self, api_key_id: uuid.UUID) -> int: + """Count requests since the start of the current calendar month.""" + result = await self._session.execute( + text( + "SELECT count(*) FROM api_key_requests " + "WHERE api_key_id = :k AND created_at >= DATE_TRUNC('month', now())" + ), + {"k": api_key_id}, + ) + return int(result.scalar_one()) diff --git a/src/contract_check/core/db/repositories/credits.py b/src/contract_check/core/db/repositories/credits.py new file mode 100644 index 0000000..7360820 --- /dev/null +++ b/src/contract_check/core/db/repositories/credits.py @@ -0,0 +1,97 @@ +"""Credits & refund repository. + +The atomic ``UPDATE ... WHERE credits_left > 0 RETURNING`` and the idempotent +refund block are kept as raw SQL because their concurrency semantics are +load-bearing. Everything else is policy logic in ``core/credits.py``. + +This module is intentionally stateless: callers own transactions (commit after +calling reserve/refund), except for the pure ``should_refund`` policy helper. +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..enums import FailureClass, RefundPolicyLike + +if TYPE_CHECKING: + pass + +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 + + +class CreditsRepository: + """Atomic credit reservations and idempotent refunds.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def reserve(self, user_id: uuid.UUID) -> bool: + """Decrement credits_left by 1 if balance > 0. Returns True on success.""" + result = await self._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( + self, + document_id: uuid.UUID, + failure_class: FailureClass | str, + policy: RefundPolicyLike, + ) -> bool: + """Refund one credit once, idempotent via ``documents.refunded``.""" + if not should_refund(failure_class, policy): + return False + + result = await self._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 + + await self._session.execute( + text("UPDATE documents SET refunded = TRUE WHERE id = :d"), + {"d": document_id}, + ) + return True + + async def adjust(self, user_id: uuid.UUID, delta: int) -> None: + """Admin/manual credit adjustment that never drops below zero.""" + await self._session.execute( + text("UPDATE users SET credits_left = greatest(0, credits_left + :d) WHERE id = :u"), + {"d": delta, "u": user_id}, + ) + + async def get_balance(self, user_id: uuid.UUID) -> int: + result = await self._session.execute( + text("SELECT credits_left FROM users WHERE id = :u"), + {"u": user_id}, + ) + row = result.first() + return int(row[0]) if row else 0 diff --git a/src/contract_check/core/db/repositories/documents.py b/src/contract_check/core/db/repositories/documents.py new file mode 100644 index 0000000..3bba5ed --- /dev/null +++ b/src/contract_check/core/db/repositories/documents.py @@ -0,0 +1,226 @@ +"""Document data access. + +Keeps raw ``SELECT ... FOR UPDATE`` used by workers for race-safe idempotency, +but otherwise uses the ORM model now that it matches the schema. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Document + + +@dataclass(frozen=True, slots=True) +class DocumentStatus: + """B2B API status payload (document + optional report).""" + + id: uuid.UUID + status: str + stage: str | None + filename: str + created_at: Any + markdown: str | None + content_json: Any + model_used: str | None + prompt_tokens: int | None + eval_tokens: int | None + latency_ms: int | None + + +class DocumentRepository: + """All document reads/writes.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_status(self, document_id: uuid.UUID) -> str | None: + """Plain status lookup (no lock).""" + result = await self._session.execute( + text("SELECT status FROM documents WHERE id = :d"), + {"d": document_id}, + ) + row = result.first() + return str(row[0]) if row else None + + async def get_status_for_update(self, document_id: uuid.UUID) -> str | None: + """Worker idempotency check with row lock.""" + result = await self._session.execute( + text("SELECT status FROM documents WHERE id = :d FOR UPDATE"), + {"d": document_id}, + ) + row = result.first() + return str(row[0]) if row else None + + async def get_status_and_filename_for_update( + self, document_id: uuid.UUID + ) -> tuple[str | None, str | None]: + """Analyze worker needs the filename for rendering while locking status.""" + result = await self._session.execute( + text("SELECT status, filename FROM documents WHERE id = :d FOR UPDATE"), + {"d": document_id}, + ) + row = result.first() + if row is None: + return None, None + return (None if row[0] is None else str(row[0]), str(row[1])) + + async def get_with_report_by_id_for_user( + self, document_id: uuid.UUID, user_id: uuid.UUID + ) -> DocumentStatus | None: + """B2B API scoped status + report lookup.""" + result = await self._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 DocumentStatus( + 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 exists(self, document_id: uuid.UUID) -> bool: + result = await self._session.execute( + text("SELECT 1 FROM documents WHERE id = :d"), + {"d": document_id}, + ) + return result.first() is not None + + async def create( + self, + *, + document_id: uuid.UUID, + user_id: uuid.UUID, + s3_key: str, + filename: str, + mime: str, + bytes_: int, + status: str = "queued", + ) -> Document: + """Insert a document row returning the ORM object.""" + result = await self._session.execute( + text( + "INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) " + "VALUES (:id, :uid, :s3, :fn, :mime, :bytes, :status) " + "RETURNING id, user_id, s3_key, extracted_s3_key, filename, mime, " + " bytes, status, stage, refunded, created_at, updated_at" + ), + { + "id": document_id, + "uid": user_id, + "s3": s3_key, + "fn": filename, + "mime": mime, + "bytes": bytes_, + "status": status, + }, + ) + row = result.first() + assert row is not None + return Document( + id=row[0], + user_id=row[1], + s3_key=row[2], + extracted_s3_key=row[3], + filename=row[4], + mime=row[5], + bytes_=row[6], + status=row[7], + stage=row[8], + refunded=row[9], + created_at=row[10], + updated_at=row[11], + ) + + async def update_status( + self, + document_id: uuid.UUID, + *, + status: str | None = None, + stage: str | None = None, + extracted_s3_key: str | None = None, + ) -> None: + """Partial update of mutable document columns.""" + fields: list[str] = [] + params: dict[str, Any] = {"d": document_id} + if status is not None: + fields.append("status = :status") + params["status"] = status + if stage is not None: + fields.append("stage = :stage") + params["stage"] = stage + if extracted_s3_key is not None: + fields.append("extracted_s3_key = :key") + params["key"] = extracted_s3_key + if not fields: + return + await self._session.execute( + text(f"UPDATE documents SET {', '.join(fields)} WHERE id = :d"), + params, + ) + + async def update_stage(self, document_id: uuid.UUID, *, stage: str) -> None: + await self.update_status(document_id, stage=stage) + + async def mark_failed(self, document_id: uuid.UUID, stage: str) -> None: + await self._session.execute( + text("UPDATE documents SET status = 'failed', stage = :stage WHERE id = :d"), + {"stage": stage, "d": document_id}, + ) + + async def get_by_id(self, document_id: uuid.UUID) -> Document | None: + return await self._session.get(Document, document_id) + + async def list_for_user( + self, user_id: uuid.UUID, *, limit: int = 100, offset: int = 0 + ) -> list[Document]: + result = await self._session.execute( + select(Document) + .where(Document.user_id == user_id) + .order_by(Document.created_at.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()) + + async def stats_for_user(self, user_id: uuid.UUID) -> dict[str, int]: + result = await self._session.execute( + text( + "SELECT count(*), " + " count(*) FILTER (WHERE status = 'done'), " + " count(*) FILTER (WHERE status = 'failed') " + "FROM documents WHERE user_id = :u" + ), + {"u": user_id}, + ) + row = result.first() + if row is None: + return {"docs_total": 0, "docs_done": 0, "docs_failed": 0} + return { + "docs_total": int(row[0] or 0), + "docs_done": int(row[1] or 0), + "docs_failed": int(row[2] or 0), + } diff --git a/src/contract_check/core/db/repositories/jobs.py b/src/contract_check/core/db/repositories/jobs.py new file mode 100644 index 0000000..0887a6b --- /dev/null +++ b/src/contract_check/core/db/repositories/jobs.py @@ -0,0 +1,175 @@ +"""Job queue data access. + +Uses raw SQL for upserts/ON CONFLICT and row-level state transitions. +""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Job + + +class JobRepository: + """All jobs reads/writes.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_document_id_and_queue(self, document_id: uuid.UUID, queue: str) -> Job | None: + result = await self._session.execute( + text("SELECT * FROM jobs WHERE document_id = :d AND queue = :q"), + {"d": document_id, "q": queue}, + ) + row = result.mappings().first() + if row is None: + return None + return Job(**dict(row)) + + async def upsert_running( + self, document_id: uuid.UUID, correlation_id: uuid.UUID, queue: str + ) -> None: + """Prescreen worker idempotently creates the prescreen row then marks running.""" + await self._session.execute( + text( + "INSERT INTO jobs (document_id, correlation_id, queue, status) " + "VALUES (:d, :cid, :q, 'running') " + "ON CONFLICT (document_id, queue) DO NOTHING" + ), + {"d": document_id, "cid": correlation_id, "q": queue}, + ) + await self._session.execute( + text( + "UPDATE jobs SET status = 'running', attempts = attempts + 1 " + "WHERE document_id = :d AND queue = :q" + ), + {"d": document_id, "q": queue}, + ) + + async def claim_start(self, document_id: uuid.UUID, queue: str) -> None: + """Mark a job running and bump attempts.""" + await self._session.execute( + text( + "UPDATE jobs SET status = 'running', attempts = attempts + 1 " + "WHERE document_id = :d AND queue = :q" + ), + {"d": document_id, "q": queue}, + ) + + async def mark_done(self, document_id: uuid.UUID, queue: str) -> None: + await self._session.execute( + text("UPDATE jobs SET status = 'done' WHERE document_id = :d AND queue = :q"), + {"d": document_id, "q": queue}, + ) + + async def mark_retrying( + self, + document_id: uuid.UUID, + queue: str, + *, + attempt: int, + failure_class: str, + error: str, + ) -> None: + await self._session.execute( + text( + "UPDATE jobs SET status = 'retrying', attempts = :a, " + "last_failure_class = :fc, last_error = :err " + "WHERE document_id = :d AND queue = :q" + ), + { + "a": attempt, + "fc": failure_class, + "err": error[:1000], + "d": document_id, + "q": queue, + }, + ) + + async def mark_dlq( + self, + document_id: uuid.UUID, + queue: str, + *, + failure_class: str, + error: str, + ) -> None: + await self._session.execute( + text( + "UPDATE jobs SET status = 'dlq', dlq = TRUE, " + "last_failure_class = :fc, last_error = :err " + "WHERE document_id = :d AND queue = :q" + ), + { + "fc": failure_class, + "err": error[:1000], + "d": document_id, + "q": queue, + }, + ) + + async def create( + self, + *, + document_id: uuid.UUID, + correlation_id: uuid.UUID, + queue: str, + status: str = "pending", + ) -> Job: + result = await self._session.execute( + text( + "INSERT INTO jobs (document_id, correlation_id, queue, status) " + "VALUES (:d, :cid, :q, :s) " + "RETURNING id, document_id, correlation_id, queue, attempts, max_attempts, " + "last_failure_class, last_error, dlq, status, created_at, updated_at" + ), + {"d": document_id, "cid": correlation_id, "q": queue, "s": status}, + ) + row = result.first() + assert row is not None + return Job( + id=row[0], + document_id=row[1], + correlation_id=row[2], + queue=row[3], + attempts=row[4], + max_attempts=row[5], + last_failure_class=row[6], + last_error=row[7], + dlq=row[8], + status=row[9], + created_at=row[10], + updated_at=row[11], + ) + + async def count_active_by_document(self, document_id: uuid.UUID) -> int: + result = await self._session.execute( + text( + "SELECT count(*) FROM jobs " + "WHERE document_id = :d AND status IN ('pending','running','retrying')" + ), + {"d": document_id}, + ) + return int(result.scalar_one()) + + async def count_dlq_by_document(self, document_id: uuid.UUID) -> int: + result = await self._session.execute( + text("SELECT count(*) FROM jobs WHERE document_id = :d AND dlq = TRUE"), + {"d": document_id}, + ) + return int(result.scalar_one()) + + async def list_for_document( + self, document_id: uuid.UUID, *, limit: int = 100, offset: int = 0 + ) -> list[Job]: + result = await self._session.execute( + text( + "SELECT * FROM jobs WHERE document_id = :d " + "ORDER BY created_at DESC LIMIT :limit OFFSET :offset" + ), + {"d": document_id, "limit": limit, "offset": offset}, + ) + return [Job(**dict(row)) for row in result.mappings().all()] diff --git a/src/contract_check/core/db/repositories/passkeys.py b/src/contract_check/core/db/repositories/passkeys.py new file mode 100644 index 0000000..75efe19 --- /dev/null +++ b/src/contract_check/core/db/repositories/passkeys.py @@ -0,0 +1,45 @@ +"""Passkey credential data access.""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import PasskeyCredential + + +class PasskeyRepository: + """WebAuthn passkey credential reads/writes.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id_for_user( + self, passkey_id: uuid.UUID, user_id: uuid.UUID + ) -> PasskeyCredential | None: + result = await self._session.execute( + select(PasskeyCredential).where( + PasskeyCredential.id == passkey_id, + PasskeyCredential.user_id == user_id, + ) + ) + return result.scalars().first() + + async def delete_by_id_for_user(self, passkey_id: uuid.UUID, user_id: uuid.UUID) -> bool: + result = await self._session.execute( + delete(PasskeyCredential).where( + PasskeyCredential.id == passkey_id, + PasskeyCredential.user_id == user_id, + ) + ) + return bool(getattr(result, "rowcount", 0) or 0) + + async def list_for_user(self, user_id: uuid.UUID) -> list[PasskeyCredential]: + result = await self._session.execute( + select(PasskeyCredential) + .where(PasskeyCredential.user_id == user_id) + .order_by(PasskeyCredential.created_at.desc()) + ) + return list(result.scalars().all()) diff --git a/src/contract_check/core/db/repositories/prescreen_results.py b/src/contract_check/core/db/repositories/prescreen_results.py new file mode 100644 index 0000000..cccbca4 --- /dev/null +++ b/src/contract_check/core/db/repositories/prescreen_results.py @@ -0,0 +1,98 @@ +"""Prescreen result data access.""" + +from __future__ import annotations + +import datetime as dt +import decimal +import json +import uuid +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import PrescreenResult + + +class PrescreenResultRepository: + """Reads/writes for the prescreen_results table.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create_from_completed( + self, + *, + document_id: uuid.UUID, + correlation_id: uuid.UUID, + contract_type: str | None, + party_a: str | None, + party_b: str | None, + total_amount: decimal.Decimal | float | int | None, + currency: str | None, + start_date: dt.date | None, + end_date: dt.date | None, + has_penalty_clause: bool | None, + has_termination_clause: bool | None, + has_arbitration: bool | None, + confidence_score: decimal.Decimal | float | int | None, + routing_decision: str, + prescreened_at: dt.datetime, + processing_ms: int, + auto_summary: str | None, + auto_findings: list[dict[str, Any]] | None, + extractor_version: str, + ) -> uuid.UUID: + """Insert a prescreen result row as worker_prescreen did previously.""" + result = await self._session.execute( + text( + "INSERT INTO prescreen_results " + "(document_id, correlation_id, contract_type, party_a, party_b, " + " total_amount, currency, start_date, end_date, has_penalty_clause, " + " has_termination_clause, has_arbitration, confidence_score, " + " routing_decision, prescreened_at, processing_ms, auto_summary, " + " auto_findings, extractor_version) " + "VALUES (:d, :cid, :ct, :pa, :pb, :ta, :cur, :sd, :ed, :hpc, :htc, :ha, " + " :cs, :rd, :psa, :ms, :asum, :af, :ev) " + "RETURNING id" + ), + { + "d": document_id, + "cid": correlation_id, + "ct": contract_type, + "pa": party_a, + "pb": party_b, + "ta": total_amount, + "cur": currency, + "sd": start_date, + "ed": end_date, + "hpc": has_penalty_clause, + "htc": has_termination_clause, + "ha": has_arbitration, + "cs": confidence_score, + "rd": routing_decision, + "psa": prescreened_at, + "ms": processing_ms, + "asum": auto_summary, + "af": json.dumps(auto_findings or []), + "ev": extractor_version, + }, + ) + row = result.first() + assert row is not None + return row[0] + + async def get_by_id(self, prescreen_result_id: uuid.UUID) -> PrescreenResult | None: + return await self._session.get(PrescreenResult, prescreen_result_id) + + async def list_for_document( + self, document_id: uuid.UUID, *, limit: int = 10 + ) -> list[PrescreenResult]: + result = await self._session.execute( + text( + "SELECT * FROM prescreen_results WHERE document_id = :d " + "ORDER BY prescreened_at DESC LIMIT :limit" + ), + {"d": document_id, "limit": limit}, + ) + return [PrescreenResult(**dict(row)) for row in result.mappings().all()] diff --git a/src/contract_check/core/db/repositories/reports.py b/src/contract_check/core/db/repositories/reports.py new file mode 100644 index 0000000..9c46099 --- /dev/null +++ b/src/contract_check/core/db/repositories/reports.py @@ -0,0 +1,144 @@ +"""Report data access. + +Both the analyze worker upsert and the prescreen auto-approve report live here. +""" + +from __future__ import annotations + +import json +import uuid +from typing import Any + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Report + + +class ReportRepository: + """All report reads/writes.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_document_id(self, document_id: uuid.UUID) -> Report | None: + result = await self._session.execute( + select(Report).where(Report.document_id == document_id) + ) + return result.scalars().first() + + async def upsert_analyze_report( + self, + *, + document_id: uuid.UUID, + content_json: dict[str, Any], + markdown: str, + model_used: str | None, + prompt_tokens: int, + eval_tokens: int, + latency_ms: int, + ) -> uuid.UUID: + """Idempotent insert for the analyze worker (ON CONFLICT document_id).""" + content_text = json.dumps(content_json, ensure_ascii=False) + result = await self._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 " + "RETURNING id" + ), + { + "d": document_id, + "content": content_text, + "md": markdown, + "model": model_used, + "pt": prompt_tokens, + "et": eval_tokens, + "lat": latency_ms, + }, + ) + row = result.first() + assert row is not None + return row[0] + + async def upsert_auto_approved( + self, + *, + document_id: uuid.UUID, + content_json: dict[str, Any], + markdown: str, + prescreen_result_id: uuid.UUID, + prescreen_meta: dict[str, Any], + ) -> uuid.UUID: + """Lightweight auto-approve report written by worker_prescreen.""" + result = await self._session.execute( + text( + "INSERT INTO reports " + "(document_id, content_json, markdown, model_used, prompt_tokens, " + " eval_tokens, latency_ms, prescreen_result_id, prescreen_meta) " + "VALUES (:d, CAST(:content AS jsonb), :md, :model, 0, 0, 0, :prid, :pm) " + "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, " + " prescreen_result_id = EXCLUDED.prescreen_result_id, " + " prescreen_meta = EXCLUDED.prescreen_meta " + "RETURNING id" + ), + { + "d": document_id, + "content": json.dumps(content_json), + "md": markdown, + "model": "prescreen-auto", + "prid": prescreen_result_id, + "pm": json.dumps(prescreen_meta), + }, + ) + row = result.first() + assert row is not None + return row[0] + + async def set_prescreen_result( + self, + document_id: uuid.UUID, + *, + prescreen_result_id: uuid.UUID, + prescreen_meta: dict[str, Any] | None = None, + ) -> None: + """Attach an existing prescreen result to a report.""" + await self._session.execute( + text( + "UPDATE reports SET prescreen_result_id = :prid, prescreen_meta = :pm " + "WHERE document_id = :d" + ), + { + "d": document_id, + "prid": prescreen_result_id, + "pm": None if prescreen_meta is None else json.dumps(prescreen_meta), + }, + ) + + async def list_for_user( + self, user_id: uuid.UUID, *, limit: int = 100, offset: int = 0 + ) -> list[Report]: + """Reports owned by a user via the document relationship.""" + result = await self._session.execute( + select(Report) + .join(Report.document) + .where(Report.document.has(user_id=user_id)) # type: ignore[attr-defined] + .order_by(Report.created_at.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()) diff --git a/src/contract_check/core/db/repositories/service_tokens.py b/src/contract_check/core/db/repositories/service_tokens.py new file mode 100644 index 0000000..b9b9b41 --- /dev/null +++ b/src/contract_check/core/db/repositories/service_tokens.py @@ -0,0 +1,44 @@ +"""Service-token data access (bot/web/cli adapters).""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +class ServiceTokenRepository: + """Service-token validation + last-used tracking.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_id_by_hash(self, token_hash: str) -> uuid.UUID | None: + """Return token id if hash is valid and not revoked.""" + result = await self._session.execute( + text("SELECT id FROM service_tokens WHERE token_hash = :h AND revoked = FALSE"), + {"h": token_hash}, + ) + row = result.first() + return row[0] if row else None + + async def bump_last_used(self, token_id: uuid.UUID) -> None: + await self._session.execute( + text("UPDATE service_tokens SET last_used_at = now() WHERE id = :id"), + {"id": token_id}, + ) + + async def upsert(self, *, name: str, token_hash: str, adapter: str) -> None: + """Insert or replace a service token by name.""" + await self._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": token_hash, "adapter": adapter}, + ) diff --git a/src/contract_check/core/db/repositories/users.py b/src/contract_check/core/db/repositories/users.py new file mode 100644 index 0000000..86a7e51 --- /dev/null +++ b/src/contract_check/core/db/repositories/users.py @@ -0,0 +1,398 @@ +"""User data access. + +Replaces raw ``text()`` SELECT/UPDATE/INSERT blocks in api/deps.py and +api/admin/users.py. Magic-link token handling is also centralized here. +""" + +from __future__ import annotations + +import datetime as dt +import json +import uuid +from typing import Any + +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import User + + +class UserRepository: + """All user reads/writes.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, user_id: uuid.UUID) -> User | None: + return await self._session.get(User, user_id) + + async def get_by_id_or_none(self, user_id: uuid.UUID) -> User | None: + """Alias used by auth dependency; returns None instead of raising.""" + return await self.get_by_id(user_id) + + async def get_by_email(self, email: str) -> User | None: + result = await self._session.execute(select(User).where(User.email == email)) + return result.scalars().first() + + async def get_password_reset_window( + self, token_hash: str + ) -> tuple[uuid.UUID, dt.datetime] | None: + """Return (user_id, expires_at) for a valid password-reset token hash.""" + result = await self._session.execute( + text( + "SELECT id, password_reset_expires_at FROM users WHERE password_reset_token_hash = :h" + ), + {"h": token_hash}, + ) + row = result.first() + if row is None: + return None + return row[0], row[1] + + async def get_by_telegram_id(self, telegram_id: int) -> User | None: + result = await self._session.execute(select(User).where(User.telegram_id == telegram_id)) + return result.scalars().first() + + async def exists(self, user_id: uuid.UUID) -> bool: + result = await self._session.execute( + text("SELECT 1 FROM users WHERE id = :u"), + {"u": user_id}, + ) + return result.first() is not None + + async def telegram_id_exists_for_other_user( + self, telegram_id: int, exclude_user_id: uuid.UUID + ) -> bool: + result = await self._session.execute( + text("SELECT 1 FROM users WHERE telegram_id = :t AND id != :u"), + {"t": telegram_id, "u": exclude_user_id}, + ) + return result.first() is not None + + async def email_exists(self, email: str) -> bool: + result = await self._session.execute( + text("SELECT 1 FROM users WHERE email = :e"), + {"e": email}, + ) + return result.first() is not None + + async def telegram_id_exists(self, telegram_id: int) -> bool: + result = await self._session.execute( + text("SELECT 1 FROM users WHERE telegram_id = :t"), + {"t": telegram_id}, + ) + return result.first() is not None + + async def create_telegram_user( + self, + telegram_id: int, + *, + profile: dict[str, object] | None = None, + verified: bool = True, + credits_left: int = 0, + ) -> User: + profile_json = json.dumps(profile, ensure_ascii=False) if profile else None + bound_at = dt.datetime.now(tz=dt.UTC) + result = await self._session.execute( + text( + "INSERT INTO users (telegram_id, credits_left, telegram_profile_json, " + "telegram_verified, telegram_bound_at) " + "VALUES (:t, :c, :p, :v, :b) " + "RETURNING id, telegram_id, created_at, credits_left, is_active, " + "telegram_verified" + ), + { + "t": telegram_id, + "c": credits_left, + "p": profile_json, + "v": verified, + "b": bound_at, + }, + ) + row = result.first() + assert row is not None + return User( + id=row[0], + telegram_id=row[1], + created_at=row[2], + credits_left=row[3], + is_active=row[4], + telegram_verified=verified, + ) + + async def create_email_user( + self, + *, + email: str, + name: str | None, + password_hash: str, + credits_left: int = 0, + role: str = "user", + telegram_id: int | None = None, + is_active: bool = True, + ) -> User: + result = await self._session.execute( + text( + "INSERT INTO users (email, name, password_hash, credits_left, role, telegram_id, is_active) " + "VALUES (:e, :n, :p, :c, :r, :t, :a) " + "RETURNING id, telegram_id, email, name, password_hash, role, is_active, " + "created_at, credits_left, telegram_verified" + ), + { + "e": email, + "n": name, + "p": password_hash, + "c": credits_left, + "r": role, + "t": telegram_id, + "a": is_active, + }, + ) + row = result.first() + assert row is not None + return User( + id=row[0], + telegram_id=row[1], + email=row[2], + name=row[3], + password_hash=row[4], + role=row[5], + is_active=row[6], + created_at=row[7], + credits_left=row[8], + telegram_verified=row[9], + ) + + async def update_telegram_profile( + self, + user_id: uuid.UUID, + *, + profile: dict[str, object] | None = None, + verified: bool | None = None, + ) -> None: + fields: list[str] = [] + params: dict[str, Any] = {"u": user_id} + if profile is not None: + fields.append("telegram_profile_json = :p") + params["p"] = json.dumps(profile, ensure_ascii=False) + if verified is not None: + fields.append("telegram_verified = :v") + params["v"] = verified + if not fields: + return + await self._session.execute( + text(f"UPDATE users SET {', '.join(fields)} WHERE id = :u"), + params, + ) + + async def bind_telegram( + self, user_id: uuid.UUID, telegram_id: int, *, bound_at: dt.datetime | None = None + ) -> None: + await self._session.execute( + text("UPDATE users SET telegram_id = :t, telegram_bound_at = :b WHERE id = :u"), + { + "t": telegram_id, + "u": user_id, + "b": bound_at or func.now(), + }, + ) + + async def set_password(self, user_id: uuid.UUID, password_hash: str) -> None: + await self._session.execute( + text("UPDATE users SET password_hash = :p WHERE id = :u"), + {"p": password_hash, "u": user_id}, + ) + + async def reset_password(self, user_id: uuid.UUID, password_hash: str) -> None: + """Rotate the password and invalidate the password-reset token.""" + await self._session.execute( + text( + "UPDATE users SET password_hash = :p, " + "password_reset_token_hash = NULL, password_reset_expires_at = NULL " + "WHERE id = :u" + ), + {"p": password_hash, "u": user_id}, + ) + + async def set_password_reset_token( + self, + user_id: uuid.UUID, + token_hash: str, + expires_at: dt.datetime, + ) -> None: + await self._session.execute( + text( + "UPDATE users SET password_reset_token_hash = :h, " + "password_reset_expires_at = :e WHERE id = :u" + ), + {"h": token_hash, "e": expires_at, "u": user_id}, + ) + + async def set_name(self, user_id: uuid.UUID, name: str) -> None: + await self._session.execute( + text("UPDATE users SET name = :n WHERE id = :u"), + {"n": name, "u": user_id}, + ) + + async def set_active(self, user_id: uuid.UUID, active: bool) -> None: + await self._session.execute( + text("UPDATE users SET is_active = :a WHERE id = :u"), + {"a": active, "u": user_id}, + ) + + async def toggle_active(self, user_id: uuid.UUID) -> None: + await self._session.execute( + text("UPDATE users SET is_active = NOT is_active WHERE id = :u"), + {"u": user_id}, + ) + + async def set_role(self, user_id: uuid.UUID, role: str) -> None: + await self._session.execute( + text("UPDATE users SET role = :r WHERE id = :u"), + {"r": role, "u": user_id}, + ) + + async def verify_telegram(self, user_id: uuid.UUID) -> None: + await self._session.execute( + text("UPDATE users SET telegram_verified = TRUE WHERE id = :u"), + {"u": user_id}, + ) + + async def adjust_credits(self, user_id: uuid.UUID, delta: int) -> None: + await self._session.execute( + text("UPDATE users SET credits_left = greatest(0, credits_left + :d) WHERE id = :u"), + {"d": delta, "u": user_id}, + ) + + async def get_credits(self, user_id: uuid.UUID) -> int | None: + result = await self._session.execute( + text("SELECT credits_left FROM users WHERE id = :u"), + {"u": user_id}, + ) + row = result.first() + return None if row is None else int(row[0]) + + async def set_magic_link_token( + self, + user_id: uuid.UUID, + token_hash: str, + expires_at: dt.datetime, + ) -> None: + await self._session.execute( + text( + "UPDATE users SET magic_link_token_hash = :h, " + "magic_link_expires_at = :e WHERE id = :u" + ), + {"h": token_hash, "e": expires_at, "u": user_id}, + ) + + async def get_by_magic_link_token_hash(self, token_hash: str) -> User | None: + result = await self._session.execute( + text("SELECT id, magic_link_expires_at FROM users WHERE magic_link_token_hash = :h"), + {"h": token_hash}, + ) + row = result.first() + if row is None: + return None + # Build a lightweight User shell with only the loaded columns. + return User(id=row[0], magic_link_expires_at=row[1]) + + async def consume_magic_link_token(self, user_id: uuid.UUID) -> None: + await self._session.execute( + text( + "UPDATE users SET magic_link_token_hash = NULL, " + "magic_link_expires_at = NULL WHERE id = :u" + ), + {"u": user_id}, + ) + + async def list_users( + self, + *, + role: str | None = None, + is_active: bool | None = None, + has_telegram: bool | None = None, + telegram_verified: bool | None = None, + search: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[User]: + clauses: list[str] = ["1 = 1"] + params: dict[str, Any] = {"limit": limit, "offset": offset} + if role is not None: + clauses.append("role = :role") + params["role"] = role + if is_active is not None: + clauses.append("is_active = :active") + params["active"] = is_active + if has_telegram is not None: + clauses.append("telegram_id IS NOT NULL" if has_telegram else "telegram_id IS NULL") + if telegram_verified is not None: + clauses.append("telegram_verified = :tv") + params["tv"] = telegram_verified + if search: + clauses.append("(email ILIKE :s OR telegram_id::text ILIKE :s OR name ILIKE :s)") + params["s"] = f"%{search}%" + where = " AND ".join(clauses) + result = await self._session.execute( + text( + f"SELECT {self._user_columns()} FROM users WHERE {where} " + "ORDER BY created_at DESC LIMIT :limit OFFSET :offset" + ), + params, + ) + return [self._row_to_user(row) for row in result.all()] + + async def count_users( + self, + *, + role: str | None = None, + is_active: bool | None = None, + has_telegram: bool | None = None, + telegram_verified: bool | None = None, + search: str | None = None, + ) -> int: + clauses: list[str] = ["1 = 1"] + params: dict[str, Any] = {} + if role is not None: + clauses.append("role = :role") + params["role"] = role + if is_active is not None: + clauses.append("is_active = :active") + params["active"] = is_active + if has_telegram is not None: + clauses.append("telegram_id IS NOT NULL" if has_telegram else "telegram_id IS NULL") + if telegram_verified is not None: + clauses.append("telegram_verified = :tv") + params["tv"] = telegram_verified + if search: + clauses.append("(email ILIKE :s OR telegram_id::text ILIKE :s OR name ILIKE :s)") + params["s"] = f"%{search}%" + where = " AND ".join(clauses) + result = await self._session.execute( + text(f"SELECT count(*) FROM users WHERE {where}"), + params, + ) + return int(result.scalar_one()) + + @staticmethod + def _user_columns() -> str: + return ( + "id, telegram_id, email, name, password_hash, role, is_active, " + "created_at, credits_left, telegram_verified" + ) + + @staticmethod + def _row_to_user(row: Any) -> User: + return User( + id=row[0], + telegram_id=row[1], + email=row[2], + name=row[3], + password_hash=row[4], + role=row[5], + is_active=row[6], + created_at=row[7], + credits_left=row[8], + telegram_verified=row[9], + ) diff --git a/src/contract_check/core/extraction/__init__.py b/src/contract_check/core/extraction/__init__.py index 9958ce0..1339bbb 100644 --- a/src/contract_check/core/extraction/__init__.py +++ b/src/contract_check/core/extraction/__init__.py @@ -13,7 +13,8 @@ from .adapters.ocr_tesseract import TesseractOcrExtractor from .adapters.pdf_pymupdf import PyMuPDFExtractor from .adapters.rtf_striprtf import RtfExtractor from .adapters.txt_chardet import TxtExtractor -from .factory import SUPPORTED_SUFFIXES, ExtractorFactory, detect_format, get_factory +from .factory import ExtractorFactory, detect_format, get_factory +from .formats import SUPPORTED_SUFFIXES from .port import ( MIN_TEXT_CHARS, DocumentExtractor, diff --git a/src/contract_check/core/extraction/factory.py b/src/contract_check/core/extraction/factory.py index 0d4e03a..9278041 100644 --- a/src/contract_check/core/extraction/factory.py +++ b/src/contract_check/core/extraction/factory.py @@ -24,22 +24,9 @@ from .adapters.ocr_tesseract import TesseractOcrExtractor from .adapters.pdf_pymupdf import PyMuPDFExtractor from .adapters.rtf_striprtf import RtfExtractor from .adapters.txt_chardet import TxtExtractor +from .formats import SUPPORTED_SUFFIXES from .port import DocumentExtractor, UnsupportedFormatError -# Canonical upload gate (mirrored by the api and the bot adapter). -SUPPORTED_SUFFIXES = { - ".pdf", - ".docx", - ".rtf", - ".txt", - ".csv", - ".png", - ".jpg", - ".jpeg", - ".tif", - ".tiff", -} - # Canonical format ids → suffix(es). `image` covers every raster suffix. _SUFFIX_TO_FORMAT = { ".pdf": "pdf", diff --git a/src/contract_check/core/extraction/formats.py b/src/contract_check/core/extraction/formats.py new file mode 100644 index 0000000..b020255 --- /dev/null +++ b/src/contract_check/core/extraction/formats.py @@ -0,0 +1,21 @@ +"""Canonical supported-format definitions. + +Kept lightweight so the bot adapter and the API can share the suffix gate +without importing heavy adapter implementations (PyMuPDF, Mammoth, Tesseract). +""" + +from __future__ import annotations + +# Upload gate shared by the API, the bot adapter, and extraction tooling. +SUPPORTED_SUFFIXES: set[str] = { + ".pdf", + ".docx", + ".rtf", + ".txt", + ".csv", + ".png", + ".jpg", + ".jpeg", + ".tif", + ".tiff", +} diff --git a/src/contract_check/core/llm/errors.py b/src/contract_check/core/llm/errors.py new file mode 100644 index 0000000..ac00955 --- /dev/null +++ b/src/contract_check/core/llm/errors.py @@ -0,0 +1,34 @@ +"""Canonical LLM error hierarchy shared by all provider adapters. + +Provider modules re-export these classes so existing import paths keep +working; classification/retry logic must import from here, never from a +specific adapter (see issue 002). +""" + +from __future__ import annotations + +from ..errors import TerminalError + + +class LLMError(Exception): + """Unrecoverable LLM failure (after all retries).""" + + +class LLMQuotaError(LLMError): + """Quota/rate-limit exhausted on both primary and fallback — refundable failure.""" + + +class LLMUnavailableError(LLMError): + """Provider API unreachable or returns non-200 status — retryable.""" + + +class LLMConfigError(LLMError, TerminalError): + """Misconfigured provider credentials/endpoint — terminal, do not retry.""" + + +__all__ = [ + "LLMConfigError", + "LLMError", + "LLMQuotaError", + "LLMUnavailableError", +] diff --git a/src/contract_check/core/tokens.py b/src/contract_check/core/tokens.py index a3c1706..5b86c43 100644 --- a/src/contract_check/core/tokens.py +++ b/src/contract_check/core/tokens.py @@ -9,7 +9,6 @@ dependency that uses these lives in `api/deps.py` (Step 2). from __future__ import annotations import hashlib -import hmac import secrets from typing import cast @@ -26,12 +25,6 @@ def hash_token(token: str) -> str: 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 diff --git a/src/contract_check/worker_analyze/handler.py b/src/contract_check/worker_analyze/handler.py index e6abcf9..3baccdf 100644 --- a/src/contract_check/worker_analyze/handler.py +++ b/src/contract_check/worker_analyze/handler.py @@ -12,17 +12,15 @@ in-process without spinning up a real RabbitMQ consumer. It owns: 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.db.repositories import DocumentRepository, JobRepository, ReportRepository from ..core.llm.errors import ( LLMConfigError, LLMError, @@ -75,17 +73,12 @@ class AnalyzeHandler: 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])) + return await DocumentRepository(session).get_status_and_filename_for_update(document_id) async def handle(self, payload: DocumentExtracted) -> None: async with self._session_factory() as session: + docs = DocumentRepository(session) + jobs = JobRepository(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)) @@ -94,17 +87,8 @@ class AnalyzeHandler: 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 docs.update_status(payload.document_id, status="analyzing", stage="llm") + await jobs.claim_start(payload.document_id, "analyze") await session.commit() text_bytes = await self._storage.get(payload.extracted_s3_key) @@ -137,46 +121,22 @@ class AnalyzeHandler: 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}, + docs = DocumentRepository(session) + jobs = JobRepository(session) + reports = ReportRepository(session) + await reports.upsert_analyze_report( + document_id=payload.document_id, + content_json={"findings": [f.model_dump() for f in result.findings]}, + markdown=markdown, + model_used=result.model_used, + prompt_tokens=result.prompt_tokens, + eval_tokens=result.eval_tokens, + latency_ms=int(result.latency_sec * 1000), ) + await docs.update_status(payload.document_id, status="done", stage="done") + await jobs.mark_done(payload.document_id, "analyze") await session.commit() log.info( @@ -212,18 +172,12 @@ class AnalyzeHandler: error=error, ) 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 JobRepository(session).mark_retrying( + payload.document_id, + "analyze", + attempt=attempt, + failure_class=failure_class, + error=error, ) await session.commit() @@ -232,22 +186,12 @@ class AnalyzeHandler: ) -> 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}, + docs = DocumentRepository(session) + jobs = JobRepository(session) + await jobs.mark_dlq( + payload.document_id, "analyze", failure_class=failure_class, error=error ) + await docs.mark_failed(payload.document_id, stage=failure_class) await refund_credit( session, payload.document_id, diff --git a/src/contract_check/worker_extract/handler.py b/src/contract_check/worker_extract/handler.py index fefa499..95b0d88 100644 --- a/src/contract_check/worker_extract/handler.py +++ b/src/contract_check/worker_extract/handler.py @@ -18,11 +18,10 @@ 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.db.repositories import DocumentRepository, JobRepository from ..core.logging import get_logger from ..core.metrics import extract_duration, mq_failed, mq_published from ..core.mq.messages import ( @@ -69,15 +68,12 @@ class ExtractHandler: 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 + return await DocumentRepository(session).get_status_for_update(document_id) async def handle(self, payload: DocumentUploaded) -> None: async with self._session_factory() as session: + docs = DocumentRepository(session) + jobs = JobRepository(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)) @@ -86,20 +82,8 @@ class ExtractHandler: 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 docs.update_status(payload.document_id, status="extracting", stage="downloading") + await jobs.claim_start(payload.document_id, "extract") await session.commit() with extract_duration.time(): @@ -146,24 +130,15 @@ class ExtractHandler: mq_published.labels(queue=queue_metric).inc() async with self._session_factory() as session: - await session.execute( - text( - "UPDATE documents SET status = :status, stage = :stage, " - "extracted_s3_key = :key WHERE id = :d" - ), - { - "status": doc_status, - "stage": doc_stage, - "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}, + docs = DocumentRepository(session) + jobs = JobRepository(session) + await docs.update_status( + payload.document_id, + status=doc_status, + stage=doc_stage, + extracted_s3_key=ext_key, ) + await jobs.mark_done(payload.document_id, "extract") await session.commit() log.info( @@ -194,13 +169,8 @@ class ExtractHandler: 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 DocumentRepository(session).update_stage(document_id, stage=stage) await session.commit() def classify(self, exc: BaseException) -> FailureClass: @@ -216,22 +186,14 @@ class ExtractHandler: 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 JobRepository(session).mark_retrying( + payload.document_id, + "extract", + attempt=attempt, + failure_class=failure_class, + error=error, ) await session.commit() @@ -240,22 +202,12 @@ class ExtractHandler: ) -> 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}, + docs = DocumentRepository(session) + jobs = JobRepository(session) + await jobs.mark_dlq( + payload.document_id, "extract", failure_class=failure_class, error=error ) + await docs.mark_failed(payload.document_id, stage=failure_class) await refund_credit( session, payload.document_id, diff --git a/src/contract_check/worker_prescreen/handler.py b/src/contract_check/worker_prescreen/handler.py index dac3bbb..0c4d5c9 100644 --- a/src/contract_check/worker_prescreen/handler.py +++ b/src/contract_check/worker_prescreen/handler.py @@ -14,17 +14,20 @@ in-process without spinning up a real RabbitMQ consumer. It owns: from __future__ import annotations -import json import time import uuid from datetime import UTC, date, datetime 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.db.repositories import ( + DocumentRepository, + JobRepository, + PrescreenResultRepository, + ReportRepository, +) from ..core.logging import ( Timer, get_logger, @@ -104,14 +107,10 @@ class PrescreenHandler: 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 + return await DocumentRepository(session).get_status_for_update(document_id) async def handle(self, payload: PrescreenRequested) -> None: + """Orchestrate the prescreen pipeline in eight stages.""" started_at = time.perf_counter() log_message_processing( log, @@ -125,7 +124,103 @@ class PrescreenHandler: attempt=payload.attempt, ) - # Stage 1: Database validation + current = await self._validate_payload(payload) + if current is None: + return + await self._transition_status(payload) + text_bytes, contract_text = await self._fetch_document(payload) + meta, extraction = await self._extract_metadata(contract_text, payload.document_id) + decision = self._route(meta, payload.document_id) + + processing_ms = int((time.perf_counter() - started_at) * 1000) + prescreened_at = datetime.now(UTC) + completed = self._build_completed(payload, meta, extraction, decision, prescreened_at) + log_stage_complete( + log, + "prescreen_extraction", + processing_ms, + document_id=str(payload.document_id), + decision=decision, + confidence=meta.confidence_score, + contract_type=meta.contract_type, + extractor_version=extraction.extractor_version, + matched_fields=[ + field for field in self._meta_field_names() if getattr(meta, field) is not None + ], + missing_fields=[ + field for field in self._meta_field_names() if getattr(meta, field) is None + ], + ) + + prescreen_result_id = await self._persist( + payload, completed, extraction, processing_ms, prescreened_at + ) + prescreen_duration.labels(decision=decision).observe(processing_ms / 1000.0) + prescreen_runs.labels( + decision=decision, contract_type=meta.contract_type or "unknown" + ).inc() + prescreen_confidence.observe(meta.confidence_score) + + next_stage, doc_status = await self._publish( + payload, completed, decision, meta, prescreen_result_id + ) + await self._finalize(payload, doc_status, next_stage) + + log_message_processing( + log, + "completed", + "prescreen.q", + correlation_id=str(payload.correlation_id), + document_id=str(payload.document_id), + user_id=str(payload.user_id), + decision=decision, + confidence=meta.confidence_score, + contract_type=meta.contract_type, + processing_ms=processing_ms, + next_stage=next_stage, + prescreen_result_id=str(prescreen_result_id) if prescreen_result_id else None, + ) + + def _build_completed( + self, + payload: PrescreenRequested, + meta: PrescreenContractMeta, + extraction: ExtractionResult, + decision: RoutingDecision, + prescreened_at: datetime, + ) -> PrescreenCompleted: + """Assemble the outgoing PrescreenCompleted message from the stage outputs.""" + return PrescreenCompleted( + correlation_id=payload.correlation_id, + document_id=payload.document_id, + user_id=payload.user_id, + text_s3_key=payload.text_s3_key, + filename=payload.filename, + prescreened_at=prescreened_at.isoformat(), + contract_type=meta.contract_type, + party_a=meta.party_a, + party_b=meta.party_b, + total_amount=meta.total_amount, + currency=meta.currency, + start_date=meta.start_date, + end_date=meta.end_date, + has_penalty_clause=meta.has_penalty_clause, + has_termination_clause=meta.has_termination_clause, + has_arbitration=meta.has_arbitration, + confidence_score=meta.confidence_score, + routing_decision=decision, + auto_summary=self._router.summarize(meta, decision) + if decision == "auto_approve" + else None, + # A failed LLM fallback is recorded here (plan §3.4) — never fatal. + auto_findings=[{"llm_fallback_error": extraction.llm_fallback_error}] + if extraction.llm_fallback_error + else [], + attempt=payload.attempt, + ) + + async def _validate_payload(self, payload: PrescreenRequested) -> str | None: + """Return current document status or None if not found/terminal.""" with Timer(log, "prescreen_db_validation", document_id=str(payload.document_id)): async with self._session_factory() as session: current = await self._db_status(session, payload.document_id) @@ -136,39 +231,31 @@ class PrescreenHandler: user_id=str(payload.user_id), text_s3_key=payload.text_s3_key, ) - return + return None if current in DOC_TERMINAL: log.info( "document_already_terminal", document_id=str(payload.document_id), status=current, ) - return + return None + return current - # Stage 2: Update document status and job tracking + async def _transition_status(self, payload: PrescreenRequested) -> None: + """Move document to prescreening and upsert the running job row.""" with Timer(log, "prescreen_status_update", document_id=str(payload.document_id)): async with self._session_factory() as session: - await session.execute( - text( - "UPDATE documents SET status = 'prescreening', stage = 'extracting_meta' " - "WHERE id = :d" - ), - {"d": payload.document_id}, + docs = DocumentRepository(session) + jobs = JobRepository(session) + await docs.update_status( + payload.document_id, + status="prescreening", + stage="extracting_meta", ) - await session.execute( - text( - "INSERT INTO jobs (document_id, correlation_id, queue, status) " - "VALUES (:d, :cid, 'prescreen', 'running') " - "ON CONFLICT (document_id, queue) DO NOTHING" - ), - {"d": payload.document_id, "cid": payload.correlation_id}, - ) - await session.execute( - text( - "UPDATE jobs SET status = 'running', attempts = attempts + 1 " - "WHERE document_id = :d AND queue = 'prescreen'" - ), - {"d": payload.document_id}, + await jobs.upsert_running( + payload.document_id, + payload.correlation_id, + queue="prescreen", ) await session.commit() log_db_operation( @@ -180,7 +267,8 @@ class PrescreenHandler: stage="extracting_meta", ) - # Stage 3: Download text from S3 + async def _fetch_document(self, payload: PrescreenRequested) -> tuple[bytes, str]: + """Download the extracted Markdown from S3 and decode it.""" text_bytes: bytes contract_text: str with Timer( @@ -216,11 +304,15 @@ class PrescreenHandler: text_s3_key=payload.text_s3_key, char_count=len(contract_text), ) + return text_bytes, contract_text - # Stage 4: Extract contract metadata (heuristic Stage 1 + optional LLM Stage 2) + async def _extract_metadata( + self, contract_text: str, document_id: uuid.UUID + ) -> tuple[PrescreenContractMeta, ExtractionResult]: + """Run the hybrid metadata extractor over the contract text.""" meta: PrescreenContractMeta extraction: ExtractionResult - with Timer(log, "prescreen_meta_extraction", document_id=str(payload.document_id)): + with Timer(log, "prescreen_meta_extraction", document_id=str(document_id)): try: extraction = await self._extractor.extract(contract_text) meta = extraction.meta @@ -229,7 +321,7 @@ class PrescreenHandler: "extraction_confidence", meta.confidence_score, "ratio", - document_id=str(payload.document_id), + document_id=str(document_id), extractor_version=extraction.extractor_version, llm_fallback_error=extraction.llm_fallback_error, extracted_fields=sum( @@ -239,113 +331,64 @@ class PrescreenHandler: ) except Exception as exc: log_stage_failure( - log, "prescreen_meta_extraction", exc, document_id=str(payload.document_id) + log, "prescreen_meta_extraction", exc, document_id=str(document_id) ) raise - # Stage 5: Make routing decision + return meta, extraction + + def _route(self, meta: PrescreenContractMeta, document_id: uuid.UUID) -> RoutingDecision: + """Make the routing decision based on extracted metadata.""" decision: RoutingDecision - with Timer(log, "prescreen_routing_decision", document_id=str(payload.document_id)): + with Timer(log, "prescreen_routing_decision", document_id=str(document_id)): decision = self._router.decide(meta) log_stage_progress( log, "prescreen_routing_decision", "decision_made", - document_id=str(payload.document_id), + document_id=str(document_id), decision=decision, confidence=meta.confidence_score, ) - processing_ms = int((time.perf_counter() - started_at) * 1000) + return decision - prescreened_at = datetime.now(UTC) - prescreened_at_str = prescreened_at.isoformat() - completed = PrescreenCompleted( - correlation_id=payload.correlation_id, - document_id=payload.document_id, - user_id=payload.user_id, - text_s3_key=payload.text_s3_key, - filename=payload.filename, - prescreened_at=prescreened_at_str, - contract_type=meta.contract_type, - party_a=meta.party_a, - party_b=meta.party_b, - total_amount=meta.total_amount, - currency=meta.currency, - start_date=meta.start_date, - end_date=meta.end_date, - has_penalty_clause=meta.has_penalty_clause, - has_termination_clause=meta.has_termination_clause, - has_arbitration=meta.has_arbitration, - confidence_score=meta.confidence_score, - routing_decision=decision, - auto_summary=self._router.summarize(meta, decision) - if decision == "auto_approve" - else None, - # A failed LLM fallback is recorded here (plan §3.4) — never fatal. - auto_findings=[{"llm_fallback_error": extraction.llm_fallback_error}] - if extraction.llm_fallback_error - else [], - attempt=payload.attempt, - ) - - log_stage_complete( - log, - "prescreen_extraction", - processing_ms, - document_id=str(payload.document_id), - decision=decision, - confidence=meta.confidence_score, - contract_type=meta.contract_type, - extractor_version=extraction.extractor_version, - matched_fields=[ - field for field in self._meta_field_names() if getattr(meta, field) is not None - ], - missing_fields=[ - field for field in self._meta_field_names() if getattr(meta, field) is None - ], - ) - - # Stage 6: Persist prescreen result + async def _persist( + self, + payload: PrescreenRequested, + completed: PrescreenCompleted, + extraction: ExtractionResult, + processing_ms: int, + prescreened_at: datetime, + ) -> uuid.UUID | None: + """Persist the prescreen result row and return its id.""" prescreen_result_id: uuid.UUID | None = None with Timer(log, "prescreen_result_persistence", document_id=str(payload.document_id)): try: async with self._session_factory() as session: - result = await session.execute( - text( - "INSERT INTO prescreen_results " - "(document_id, correlation_id, contract_type, party_a, party_b, " - " total_amount, currency, start_date, end_date, has_penalty_clause, " - " has_termination_clause, has_arbitration, confidence_score, " - " routing_decision, prescreened_at, processing_ms, auto_summary, " - " auto_findings, extractor_version) " - "VALUES (:d, :cid, :ct, :pa, :pb, :ta, :cur, :sd, :ed, :hpc, :htc, :ha, " - " :cs, :rd, :psa, :ms, :asum, :af, :ev) " - "RETURNING id" - ), - { - "d": payload.document_id, - "cid": payload.correlation_id, - "ct": completed.contract_type, - "pa": completed.party_a, - "pb": completed.party_b, - "ta": completed.total_amount, - "cur": completed.currency, - "sd": _as_date(completed.start_date), - "ed": _as_date(completed.end_date), - "hpc": completed.has_penalty_clause, - "htc": completed.has_termination_clause, - "ha": completed.has_arbitration, - "cs": completed.confidence_score, - "rd": completed.routing_decision, - "psa": prescreened_at, - "ms": processing_ms, - "asum": completed.auto_summary, - "af": json.dumps(completed.auto_findings), - "ev": extraction.extractor_version, - }, + prescreen_result_id = await PrescreenResultRepository( + session + ).create_from_completed( + document_id=payload.document_id, + correlation_id=payload.correlation_id, + contract_type=completed.contract_type, + party_a=completed.party_a, + party_b=completed.party_b, + total_amount=completed.total_amount, + currency=completed.currency, + start_date=_as_date(completed.start_date), + end_date=_as_date(completed.end_date), + has_penalty_clause=completed.has_penalty_clause, + has_termination_clause=completed.has_termination_clause, + has_arbitration=completed.has_arbitration, + confidence_score=completed.confidence_score, + routing_decision=completed.routing_decision, + prescreened_at=prescreened_at, + processing_ms=processing_ms, + auto_summary=completed.auto_summary, + auto_findings=completed.auto_findings, + extractor_version=extraction.extractor_version, ) - prescreen_result_id = result.scalar() await session.commit() log_db_operation( log, @@ -360,13 +403,17 @@ class PrescreenHandler: ) raise - prescreen_duration.labels(decision=decision).observe(processing_ms / 1000.0) - prescreen_runs.labels( - decision=decision, contract_type=meta.contract_type or "unknown" - ).inc() - prescreen_confidence.observe(meta.confidence_score) + return prescreen_result_id - # Stage 7: Publish next message or complete + async def _publish( + self, + payload: PrescreenRequested, + completed: PrescreenCompleted, + decision: RoutingDecision, + meta: PrescreenContractMeta, + prescreen_result_id: uuid.UUID | None, + ) -> tuple[str, str]: + """Publish the next message or complete auto-approved reports.""" publisher = await self._publisher_instance() next_stage: str @@ -419,7 +466,7 @@ class PrescreenHandler: session_factory=self._session_factory, payload=payload, completed=completed, - prescreen_result_id=prescreen_result_id, # type: ignore[arg-type] + prescreen_result_id=prescreen_result_id, ) doc_status = "done" next_stage = "auto_approved" @@ -430,19 +477,22 @@ class PrescreenHandler: document_id=str(payload.document_id), ) - # Stage 8: Final status update + return next_stage, doc_status + + async def _finalize( + self, payload: PrescreenRequested, doc_status: str, next_stage: str + ) -> None: + """Update the final document status and mark the prescreen job done.""" with Timer(log, "prescreen_final_status_update", document_id=str(payload.document_id)): async with self._session_factory() as session: - await session.execute( - text("UPDATE documents SET status = :status, stage = :stage WHERE id = :d"), - {"status": doc_status, "stage": next_stage, "d": payload.document_id}, - ) - await session.execute( - text( - "UPDATE jobs SET status = 'done' WHERE document_id = :d AND queue = 'prescreen'" - ), - {"d": payload.document_id}, + docs = DocumentRepository(session) + jobs = JobRepository(session) + await docs.update_status( + payload.document_id, + status=doc_status, + stage=next_stage, ) + await jobs.mark_done(payload.document_id, queue="prescreen") await session.commit() log_db_operation( log, @@ -453,28 +503,13 @@ class PrescreenHandler: final_stage=next_stage, ) - log_message_processing( - log, - "completed", - "prescreen.q", - correlation_id=str(payload.correlation_id), - document_id=str(payload.document_id), - user_id=str(payload.user_id), - decision=decision, - confidence=meta.confidence_score, - contract_type=meta.contract_type, - processing_ms=processing_ms, - next_stage=next_stage, - prescreen_result_id=str(prescreen_result_id) if prescreen_result_id else None, - ) - async def _write_auto_approved_report( self, *, session_factory: async_sessionmaker[AsyncSession], payload: PrescreenRequested, completed: PrescreenCompleted, - prescreen_result_id: uuid.UUID, + prescreen_result_id: uuid.UUID | None, ) -> None: content: dict[str, object] = { "findings": [ @@ -496,30 +531,12 @@ class PrescreenHandler: "*Это автоматический отчёт без полного анализа ИИ.*" ) async with session_factory() as session: - await session.execute( - text( - "INSERT INTO reports " - "(document_id, content_json, markdown, model_used, prompt_tokens, " - " eval_tokens, latency_ms, prescreen_result_id, prescreen_meta) " - "VALUES (:d, CAST(:content AS jsonb), :md, :model, 0, 0, 0, :prid, :pm) " - "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, " - " prescreen_result_id = EXCLUDED.prescreen_result_id, " - " prescreen_meta = EXCLUDED.prescreen_meta" - ), - { - "d": payload.document_id, - "content": json.dumps(content), - "md": markdown, - "model": "prescreen-auto", - "prid": prescreen_result_id, - "pm": completed.model_dump(), - }, + await ReportRepository(session).upsert_auto_approved( + document_id=payload.document_id, + content_json=content, + markdown=markdown, + prescreen_result_id=prescreen_result_id or uuid.UUID(int=0), + prescreen_meta=completed.model_dump(), ) await session.commit() @@ -561,18 +578,12 @@ class PrescreenHandler: error=error, ) 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 = 'prescreen'" - ), - { - "a": attempt, - "fc": failure_class, - "err": error[:1000], - "d": payload.document_id, - }, + await JobRepository(session).mark_retrying( + payload.document_id, + queue="prescreen", + attempt=attempt, + failure_class=failure_class, + error=error, ) await session.commit() @@ -581,22 +592,15 @@ class PrescreenHandler: ) -> None: mq_failed.labels(queue="prescreen", 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 = 'prescreen'" - ), - { - "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}, + jobs = JobRepository(session) + docs = DocumentRepository(session) + await jobs.mark_dlq( + payload.document_id, + queue="prescreen", + failure_class=failure_class, + error=error, ) + await docs.mark_failed(payload.document_id, stage=failure_class) await refund_credit( session, payload.document_id, diff --git a/src/contract_check/worker_prescreen/router.py b/src/contract_check/worker_prescreen/router.py index 398cdb1..73ccac5 100644 --- a/src/contract_check/worker_prescreen/router.py +++ b/src/contract_check/worker_prescreen/router.py @@ -6,15 +6,12 @@ When disabled, any auto_approve decision is remapped to manual_review. from __future__ import annotations -from typing import Literal - +from ..core.db.enums import RoutingDecision from ..core.logging import get_logger from .extractor import PrescreenContractMeta log = get_logger(__name__) -type RoutingDecision = Literal["auto_approve", "manual_review", "deep_analysis"] - class PrescreenRouter: """Decide where a prescreened document goes next.""" diff --git a/tests/conftest.py b/tests/conftest.py index fb827ea..a209ff9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ """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). +the running Docker Compose infrastructure and are marked `@pytest.mark.integration` +(deselected by the default `pytest -q` run). """ from __future__ import annotations diff --git a/tests/integration/test_credits_db.py b/tests/integration/test_credits_db.py index e79230f..3dd6197 100644 --- a/tests/integration/test_credits_db.py +++ b/tests/integration/test_credits_db.py @@ -1,7 +1,8 @@ -"""Credits DB integration tests (requires real Postgres via testcontainers). +"""Credits DB integration tests (requires running Docker Compose infrastructure). Marked `integration`; not run by the default fast suite. Verifies the atomic -reserve, idempotent refund, and the race-safety of `reserve_credit`. +reserve, idempotent refund, and the race-safety of `reserve_credit` against +the real Postgres schema. """ from __future__ import annotations @@ -10,50 +11,21 @@ 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 sqlalchemy.ext.asyncio import AsyncSession 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"), + text( + "INSERT INTO users (id, email, password_hash, credits_left, is_active) " + "VALUES (gen_random_uuid(), gen_random_uuid() || '@test.local', '', :c, TRUE) " + "RETURNING id" + ), {"c": credits}, ) user_id = result.scalar_one() @@ -63,7 +35,11 @@ async def insert_user(session: AsyncSession, credits: int = 5) -> uuid.UUID: 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"), + text( + "INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) " + "VALUES (gen_random_uuid(), :u, 's3://test', 'test.pdf', 'application/pdf', 0, 'queued') " + "RETURNING id" + ), {"u": user_id}, ) doc_id = result.scalar_one() @@ -78,60 +54,63 @@ async def credit_balance(session: AsyncSession, user_id: uuid.UUID) -> int: 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) +async def test_reserve_credit_decrements_once(db_session: AsyncSession) -> None: + u = await insert_user(db_session, credits=2) + ok = await reserve_credit(db_session, u) assert ok is True - await pg_session.commit() - assert await credit_balance(pg_session, u) == 1 + await db_session.commit() + assert await credit_balance(db_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) +async def test_reserve_credit_rejects_when_zero(db_session: AsyncSession) -> None: + u = await insert_user(db_session, credits=0) + ok = await reserve_credit(db_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), - ) +async def test_reserve_credit_never_goes_negative(db_session: AsyncSession) -> None: + u = await insert_user(db_session, credits=1) + + async def _attempt() -> bool: + # Each concurrent attempt must use its own DB session/connection. + async with AsyncSession(db_session.bind) as session: + result = await reserve_credit(session, u) + await session.commit() + return result + + results = await asyncio.gather(_attempt(), _attempt(), _attempt()) # 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 + assert await credit_balance(db_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) +async def test_refund_credit_idempotent(db_session: AsyncSession) -> None: + u = await insert_user(db_session, credits=0) + d = await insert_doc(db_session, u) - ok1 = await refund_credit(pg_session, d, "llm_quota", "all") + ok1 = await refund_credit(db_session, d, "llm_quota", "all") assert ok1 is True - await pg_session.commit() - assert await credit_balance(pg_session, u) == 1 + await db_session.commit() + assert await credit_balance(db_session, u) == 1 - ok2 = await refund_credit(pg_session, d, "llm_quota", "all") + ok2 = await refund_credit(db_session, d, "llm_quota", "all") assert ok2 is False # already refunded - await pg_session.commit() - assert await credit_balance(pg_session, u) == 1 + await db_session.commit() + assert await credit_balance(db_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) +async def test_refund_credit_respects_infra_only(db_session: AsyncSession) -> None: + u = await insert_user(db_session, credits=0) + d = await insert_doc(db_session, u) - ok = await refund_credit(pg_session, d, "extraction_failed", "infra_only") + ok = await refund_credit(db_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 + await db_session.commit() + assert await credit_balance(db_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") + d2 = await insert_doc(db_session, u) + ok2 = await refund_credit(db_session, d2, "llm_quota", "infra_only") assert ok2 is True - await pg_session.commit() - assert await credit_balance(pg_session, u) == 1 + await db_session.commit() + assert await credit_balance(db_session, u) == 1 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..711fff9 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,41 @@ +"""Unit-test fixtures for repository tests against the running dev database. + +Repository tests roll back every test via a nested transaction so the shared +dev database stays clean. If the compose stack is not up, the tests are skipped. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check" + + +@pytest_asyncio.fixture +async def db_session() -> AsyncIterator[AsyncSession]: + """Open a rolled-back transaction against the dev database.""" + engine = create_async_engine(_DB_URL, poolclass=NullPool) + try: + async with engine.begin() as conn: + trans = await conn.begin_nested() + factory = async_sessionmaker( + bind=conn, + expire_on_commit=False, + autoflush=False, + autocommit=False, + ) + session = factory() + try: + yield session + finally: + await session.close() + await trans.rollback() + except OSError as exc: + pytest.skip(f"Postgres not reachable for repository tests: {exc}") + finally: + await engine.dispose() diff --git a/tests/unit/test_analyze_classify.py b/tests/unit/test_analyze_classify.py new file mode 100644 index 0000000..0c7af0b --- /dev/null +++ b/tests/unit/test_analyze_classify.py @@ -0,0 +1,56 @@ +"""Unit tests for AnalyzeHandler.classify (issue 002). + +Verifies that every canonical LLM error class — no matter which provider +module re-exports it — maps to the correct FailureClass, and that the +ollama_cloud/yandex_gpt hierarchies are the same canonical classes. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from contract_check.core.llm import errors as llm_errors +from contract_check.core.llm import ollama_cloud, yandex_gpt +from contract_check.worker_analyze.handler import AnalyzeHandler + + +def _handler() -> AnalyzeHandler: + return AnalyzeHandler(session_factory=MagicMock()) + + +@pytest.mark.parametrize( + ("error_class", "expected"), + [ + (llm_errors.LLMQuotaError, "llm_quota"), + (llm_errors.LLMConfigError, "infra"), + (llm_errors.LLMUnavailableError, "llm_timeout"), + (llm_errors.LLMError, "llm_invalid_output"), + ], +) +@pytest.mark.parametrize("provider_module", [ollama_cloud, yandex_gpt]) +def test_classify_llm_errors_from_both_providers( + error_class: type[Exception], expected: str, provider_module: object +) -> None: + """Re-exported classes from either provider must classify identically.""" + handler = _handler() + raised = error_class("boom") + assert handler.classify(raised) == expected + + +def test_provider_hierarchies_are_canonical() -> None: + """Both adapters must re-export the same canonical error classes.""" + assert ollama_cloud.LLMError is llm_errors.LLMError + assert ollama_cloud.LLMQuotaError is llm_errors.LLMQuotaError + assert ollama_cloud.LLMUnavailableError is llm_errors.LLMUnavailableError + assert ollama_cloud.LLMConfigError is llm_errors.LLMConfigError + + assert yandex_gpt.LLMError is llm_errors.LLMError + assert yandex_gpt.LLMQuotaError is llm_errors.LLMQuotaError + assert yandex_gpt.LLMUnavailableError is llm_errors.LLMUnavailableError + assert yandex_gpt.LLMConfigError is llm_errors.LLMConfigError + + +def test_classify_unknown_error_is_infra() -> None: + assert _handler().classify(RuntimeError("boom")) == "infra" diff --git a/tests/unit/test_db_models_sync.py b/tests/unit/test_db_models_sync.py new file mode 100644 index 0000000..128e6c7 --- /dev/null +++ b/tests/unit/test_db_models_sync.py @@ -0,0 +1,94 @@ +"""Model/enum layer must match the migrated schema (issue 004). + +Guards the drift fixed in .scratch/refactor-base-api-alignment/issues/004: +TEXT+CHECK status/queue sets, the jobs (document_id, queue) unique +constraint, and the PrescreenResult mapping for `prescreen_results`. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import CheckConstraint, UniqueConstraint + +from contract_check.core.db.enums import ( + DOC_STATUSES, + QUEUE_NAMES, + ROUTING_DECISIONS, +) +from contract_check.core.db.models import Base, PrescreenResult + + +class TestEnumsMatchMigrations: + def test_doc_statuses_cover_migrations_0006_and_0008(self) -> None: + assert "prescreening" in DOC_STATUSES + assert "manual_review" in DOC_STATUSES + + def test_queue_names_cover_migration_0006(self) -> None: + assert "prescreen" in QUEUE_NAMES + + def test_routing_decisions_match_prescreen_check(self) -> None: + assert ROUTING_DECISIONS == ("auto_approve", "manual_review", "deep_analysis") + + +class TestPrescreenResultModel: + @pytest.fixture + def table(self): # type: ignore[no-untyped-def] + return PrescreenResult.__table__ + + def test_mapped_to_prescreen_results(self, table) -> None: # type: ignore[no-untyped-def] + assert table.name == "prescreen_results" + assert "prescreen_results" in Base.metadata.tables + + def test_check_constraints_match_migration_0006(self, table) -> None: # type: ignore[no-untyped-def] + by_name = {c.name: c for c in table.constraints if isinstance(c, CheckConstraint)} + assert str(by_name["prescreen_results_routing_decision_check"].sqltext) == ( + "routing_decision IN ('auto_approve','manual_review','deep_analysis')" + ) + assert "confidence_score >= 0" in str(by_name["prescreen_results_confidence_check"].sqltext) + assert str(by_name["prescreen_results_retry_count_nonneg"].sqltext) == "retry_count >= 0" + + def test_indexes_match_migration_0006(self, table) -> None: # type: ignore[no-untyped-def] + names = {idx.name for idx in table.indexes} + assert names == { + "ix_prescreen_results_document_id", + "prescreen_results_routing_idx", + "prescreen_results_confidence_idx", + } + + def test_server_defaults_match_migration_0006(self, table) -> None: # type: ignore[no-untyped-def] + for column in ("routing_decision", "extractor_version", "auto_findings", "retry_count"): + assert table.c[column].server_default is not None, column + + +class TestJobsUniqueConstraint: + def test_document_queue_unique_present(self) -> None: + jobs = Base.metadata.tables["jobs"] + uniques = [ + c + for c in jobs.constraints + if isinstance(c, UniqueConstraint) and c.name == "jobs_document_queue_unique" + ] + assert len(uniques) == 1 + assert [c.name for c in uniques[0].columns] == ["document_id", "queue"] + + +class TestStatusCheckConstraints: + def test_documents_status_check_matches_migration_0008(self) -> None: + documents = Base.metadata.tables["documents"] + check = next( + c + for c in documents.constraints + if isinstance(c, CheckConstraint) and c.name == "documents_status_check" + ) + sql = str(check.sqltext) + for status in ("prescreening", "manual_review"): + assert f"'{status}'" in sql + + def test_jobs_queue_check_matches_migration_0006(self) -> None: + jobs = Base.metadata.tables["jobs"] + check = next( + c + for c in jobs.constraints + if isinstance(c, CheckConstraint) and c.name == "jobs_queue_check" + ) + assert "'prescreen'" in str(check.sqltext) diff --git a/tests/unit/test_db_session.py b/tests/unit/test_db_session.py new file mode 100644 index 0000000..478e472 --- /dev/null +++ b/tests/unit/test_db_session.py @@ -0,0 +1,127 @@ +"""Unit tests for DB session singleton pattern (issue 001). + +Verifies that the engine is created only once per process and that sessions +from the API dependency reuse the shared factory instead of building engines +on every request. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from contract_check.api.deps import get_db_session +from contract_check.core.db import session as session_mod +from contract_check.core.db.session import ( + _get_engine, + _get_session_factory, + create_engine, + dispose_engine, + get_session, +) + + +@pytest.fixture(autouse=True) +def reset_singletons() -> Any: + """Isolate the module-level singletons between tests.""" + session_mod._engine = None + session_mod._session_factory = None + yield + session_mod._engine = None + session_mod._session_factory = None + + +def _mock_session() -> AsyncMock: + session = AsyncMock() + session.__aenter__.return_value = session + session.__aexit__.return_value = False + return session + + +def _mock_factory(session: AsyncMock) -> MagicMock: + factory = MagicMock() + factory.return_value = session + return factory + + +async def test_engine_created_once_across_many_requests() -> None: + """N get_db_session invocations must build exactly one engine (AC).""" + with ( + patch("contract_check.core.db.session.create_async_engine") as mock_create, + patch("contract_check.core.db.session.async_sessionmaker") as mock_maker, + ): + mock_create.return_value = AsyncMock() + mock_maker.return_value = _mock_factory(_mock_session()) + + for _ in range(5): + agen = get_db_session() + await agen.__anext__() + await agen.aclose() + + mock_create.assert_called_once() + + +async def test_session_factory_singleton() -> None: + with patch("contract_check.core.db.session._get_engine") as mock_get_engine: + mock_get_engine.return_value = MagicMock() + + factory1 = _get_session_factory() + factory2 = _get_session_factory() + + assert factory1 is factory2 + mock_get_engine.assert_called_once() + + +async def test_get_session_uses_shared_factory() -> None: + session = _mock_session() + with patch("contract_check.core.db.session._get_session_factory") as mock_get_factory: + mock_get_factory.return_value = _mock_factory(session) + + agen = get_session() + assert await agen.__anext__() is session + await agen.aclose() + + mock_get_factory.assert_called_once() + + +async def test_get_session_accepts_explicit_factory() -> None: + session = _mock_session() + factory = _mock_factory(session) + + with patch("contract_check.core.db.session._get_session_factory") as mock_get_factory: + agen = get_session(factory=factory) + assert await agen.__anext__() is session + await agen.aclose() + + mock_get_factory.assert_not_called() + + +async def test_dispose_engine_disposes_and_resets_singletons() -> None: + with patch("contract_check.core.db.session.create_async_engine") as mock_create: + engine1 = AsyncMock() + engine2 = AsyncMock() + mock_create.side_effect = [engine1, engine2] + + assert _get_engine() is engine1 + await dispose_engine() + assert _get_engine() is engine2 + + engine1.dispose.assert_awaited_once() + assert session_mod._session_factory is None + assert mock_create.call_count == 2 + + +async def test_dispose_engine_noop_when_never_built() -> None: + with patch("contract_check.core.db.session.create_async_engine") as mock_create: + await dispose_engine() + mock_create.assert_not_called() + + +async def test_explicit_create_still_builds_fresh_engines() -> None: + with patch("contract_check.core.db.session.create_async_engine") as mock_create: + mock_create.side_effect = [AsyncMock(), AsyncMock()] + + assert create_engine() is not create_engine() + assert mock_create.call_count == 2 diff --git a/tests/unit/test_passkeys.py b/tests/unit/test_passkeys.py index 6425631..4efad3f 100644 --- a/tests/unit/test_passkeys.py +++ b/tests/unit/test_passkeys.py @@ -99,7 +99,7 @@ def test_build_registration_options_matches_frontend_schema() -> None: assert options["excludeCredentials"] == [{"id": existing, "type": "public-key"}] # Roundtrips through the API response model without coercion errors. - from contract_check.api.routes.auth import PasskeyRegistrationOptions + from contract_check.api.schemas import PasskeyRegistrationOptions assert PasskeyRegistrationOptions(**options).challenge == challenge @@ -113,7 +113,7 @@ def test_build_authentication_options_matches_frontend_schema() -> None: assert options["allowCredentials"] == [] assert options["userVerification"] == "required" - from contract_check.api.routes.auth import PasskeyAuthenticationOptions + from contract_check.api.schemas import PasskeyAuthenticationOptions assert PasskeyAuthenticationOptions(**options).rpId == "localhost" diff --git a/tests/unit/test_repositories.py b/tests/unit/test_repositories.py new file mode 100644 index 0000000..75da5ba --- /dev/null +++ b/tests/unit/test_repositories.py @@ -0,0 +1,233 @@ +"""Unit tests for the repository layer. + +Run against the compose Postgres stack; skipped automatically if it is down. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from contract_check.core.db.repositories import ( + ApiKeyRepository, + CreditsRepository, + DocumentRepository, + JobRepository, + ReportRepository, + UserRepository, +) + +pytestmark = pytest.mark.usefixtures("db_session") + + +class TestUserRepository: + async def test_create_and_get_email_user(self, db_session: AsyncSession) -> None: + repo = UserRepository(db_session) + user = await repo.create_email_user( + email="repo-test@example.com", + name="Repo Test", + password_hash="hash", + ) + assert user.email == "repo-test@example.com" + loaded = await repo.get_by_id(user.id) + assert loaded is not None + assert loaded.email == user.email + + async def test_telegram_user_unique(self, db_session: AsyncSession) -> None: + repo = UserRepository(db_session) + first = await repo.create_telegram_user(telegram_id=987654321) + second = await repo.create_telegram_user(telegram_id=987654322) + assert first.telegram_id != second.telegram_id + assert await repo.get_by_telegram_id(987654321) is not None + + async def test_password_and_credits_adjustments(self, db_session: AsyncSession) -> None: + repo = UserRepository(db_session) + user = await repo.create_email_user(email="adjust@example.com", name="A", password_hash="h") + await repo.set_password(user.id, "new_hash") + await repo.adjust_credits(user.id, 5) + loaded = await repo.get_by_id(user.id) + assert loaded is not None + assert loaded.password_hash == "new_hash" + assert loaded.credits_left == 5 + + +class TestCreditsRepository: + async def test_reserve_refund_cycle(self, db_session: AsyncSession) -> None: + users = UserRepository(db_session) + credits = CreditsRepository(db_session) + user = await users.create_email_user( + email="credits@example.com", name="C", password_hash="h" + ) + await users.adjust_credits(user.id, 10) + await db_session.commit() + + assert await credits.reserve(user.id) is True + await db_session.commit() + assert await credits.get_balance(user.id) == 9 + + # Refund requires a document, so create one. + docs = DocumentRepository(db_session) + doc = await docs.create( + document_id=uuid.uuid4(), + user_id=user.id, + s3_key="s3://x", + filename="x.pdf", + mime="application/pdf", + bytes_=123, + ) + await db_session.commit() + + refunded = await credits.refund(doc.id, "infra", "infra_only") + await db_session.commit() + assert refunded is True + assert await credits.get_balance(user.id) == 10 + + # Idempotent second refund. + assert await credits.refund(doc.id, "infra", "infra_only") is False + + +class TestDocumentRepository: + async def test_create_update_status(self, db_session: AsyncSession) -> None: + users = UserRepository(db_session) + repo = DocumentRepository(db_session) + user = await users.create_email_user(email="doc@example.com", name="D", password_hash="h") + doc_id = uuid.uuid4() + doc = await repo.create( + document_id=doc_id, + user_id=user.id, + s3_key="k", + filename="f.pdf", + mime="application/pdf", + bytes_=100, + ) + await repo.update_status(doc.id, status="extracting", stage="downloading") + status = await repo.get_status(doc.id) + assert status == "extracting" + + async def test_for_update_returns_status(self, db_session: AsyncSession) -> None: + users = UserRepository(db_session) + repo = DocumentRepository(db_session) + user = await users.create_email_user(email="lock@example.com", name="L", password_hash="h") + doc_id = uuid.uuid4() + await repo.create( + document_id=doc_id, + user_id=user.id, + s3_key="k", + filename="f.pdf", + mime="application/pdf", + bytes_=100, + ) + status, filename = await repo.get_status_and_filename_for_update(doc_id) + assert status == "queued" + assert filename == "f.pdf" + + +class TestJobRepository: + async def test_create_claim_done(self, db_session: AsyncSession) -> None: + users = UserRepository(db_session) + docs = DocumentRepository(db_session) + repo = JobRepository(db_session) + user = await users.create_email_user(email="job@example.com", name="J", password_hash="h") + doc = await docs.create( + document_id=uuid.uuid4(), + user_id=user.id, + s3_key="k", + filename="f.pdf", + mime="application/pdf", + bytes_=1, + ) + cid = uuid.uuid4() + job = await repo.create(document_id=doc.id, correlation_id=cid, queue="extract") + assert job.status == "pending" + await repo.claim_start(doc.id, "extract") + loaded = await repo.get_by_document_id_and_queue(doc.id, "extract") + assert loaded is not None + assert loaded.status == "running" + assert loaded.attempts == 1 + await repo.mark_done(doc.id, "extract") + loaded = await repo.get_by_document_id_and_queue(doc.id, "extract") + assert loaded is not None + assert loaded.status == "done" + + +class TestApiKeyRepository: + async def test_key_hash_lookup_and_quota(self, db_session: AsyncSession) -> None: + users = UserRepository(db_session) + repo = ApiKeyRepository(db_session) + user = await users.create_email_user(email="key@example.com", name="K", password_hash="h") + key = await repo.create( + user_id=user.id, name="test", key_hash="sha256-deadbeef", monthly_quota=10 + ) + found = await repo.get_by_hash("sha256-deadbeef") + assert found is not None + assert found.id == key.id + assert not found.revoked + quota, used = await repo.get_monthly_quota_state(key.id) + assert quota == 10 + assert used == 0 + await repo.bump_monthly_used(key.id) + quota, used = await repo.get_monthly_quota_state(key.id) + assert used == 1 + + +class TestReportRepository: + async def test_upsert_analyze_report(self, db_session: AsyncSession) -> None: + users = UserRepository(db_session) + docs = DocumentRepository(db_session) + repo = ReportRepository(db_session) + user = await users.create_email_user( + email="report@example.com", name="R", password_hash="h" + ) + doc = await docs.create( + document_id=uuid.uuid4(), + user_id=user.id, + s3_key="k", + filename="f.pdf", + mime="application/pdf", + bytes_=1, + ) + report_id = await repo.upsert_analyze_report( + document_id=doc.id, + content_json={"findings": []}, + markdown="# Report", + model_used="gpt-4", + prompt_tokens=10, + eval_tokens=20, + latency_ms=100, + ) + loaded = await repo.get_by_document_id(doc.id) + assert loaded is not None + assert loaded.id == report_id + assert loaded.markdown == "# Report" + + # Upsert is idempotent and updates. + await repo.upsert_analyze_report( + document_id=doc.id, + content_json={"findings": [{"x": 1}]}, + markdown="# Updated", + model_used="gpt-4", + prompt_tokens=11, + eval_tokens=21, + latency_ms=101, + ) + await db_session.refresh(loaded) + assert loaded.markdown == "# Updated" + + +class TestMagicLink: + async def test_round_trip(self, db_session: AsyncSession) -> None: + users = UserRepository(db_session) + user = await users.create_email_user(email="magic@example.com", name="M", password_hash="h") + expires = datetime.now(UTC) + timedelta(hours=1) + await users.set_magic_link_token(user.id, "sha256-abc", expires) + loaded = await users.get_by_magic_link_token_hash("sha256-abc") + assert loaded is not None + assert loaded.id == user.id + await users.consume_magic_link_token(user.id) + consumed = await users.get_by_id(user.id) + assert consumed is not None + assert consumed.magic_link_token_hash is None + assert consumed.magic_link_expires_at is None diff --git a/uv.lock b/uv.lock index 5cc5bc8..aee6633 100644 --- a/uv.lock +++ b/uv.lock @@ -287,70 +287,6 @@ 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" @@ -769,7 +705,6 @@ dev = [ { name = "jinja2" }, { name = "mammoth" }, { name = "minio" }, - { name = "mypy" }, { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-instrumentation-asgi" }, { name = "opentelemetry-instrumentation-fastapi" }, @@ -783,6 +718,7 @@ dev = [ { name = "pytesseract" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "python-docx" }, { name = "python-magic" }, { name = "python-multipart" }, @@ -792,7 +728,6 @@ dev = [ { name = "sentry-sdk" }, { name = "sqlalchemy" }, { name = "striprtf" }, - { name = "testcontainers", extra = ["minio", "rabbitmq"] }, { name = "ty" }, { name = "uvicorn", extra = ["standard"] }, { name = "webauthn" }, @@ -926,7 +861,6 @@ dev = [ { name = "jinja2", specifier = ">=3.1" }, { name = "mammoth", specifier = ">=1.8" }, { 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" }, @@ -940,6 +874,7 @@ dev = [ { name = "pytesseract", specifier = ">=0.3.10" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "python-docx", specifier = ">=1.1" }, { name = "python-magic", specifier = ">=0.4.27" }, { name = "python-multipart", specifier = ">=0.0.9" }, @@ -949,7 +884,6 @@ dev = [ { name = "sentry-sdk", specifier = ">=2" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "striprtf", specifier = ">=0.0.26" }, - { name = "testcontainers", extras = ["rabbitmq", "postgres", "minio"], specifier = ">=4" }, { name = "ty", specifier = ">=0.0.72" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, { name = "webauthn", specifier = ">=2.5" }, @@ -1008,6 +942,90 @@ prototype = [ ] s3 = [{ name = "minio", specifier = ">=7.2" }] +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + [[package]] name = "cryptography" version = "50.0.0" @@ -1076,20 +1094,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] -[[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 = "email-validator" version = "2.3.0" @@ -1441,92 +1445,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[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" @@ -1771,63 +1689,6 @@ wheels = [ { 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.1" -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/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, - { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, - { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, - { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, - { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, - { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, - { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, - { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, - { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, - { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, - { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, - { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, - { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, - { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, - { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, - { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, - { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, - { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, - { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, - { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, - { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, - { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, - { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, -] - -[[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 = "nodeenv" version = "1.10.0" @@ -2039,24 +1900,6 @@ 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" @@ -2493,6 +2336,20 @@ 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 = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-discovery" version = "1.5.2" @@ -2545,22 +2402,6 @@ 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" @@ -2738,30 +2579,6 @@ 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 = "ty" version = "0.0.72"