Phase 2 - Raw SQL migration, pytest-cov coverage gate, CI branch

triggers + coverage,  Dead tooling & config drift
This commit is contained in:
febux 2026-08-24 00:45:39 +03:00
parent 06e1256ea6
commit 2fe54ffd86
67 changed files with 4509 additions and 2669 deletions

View file

@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: [main] branches: [main, master]
pull_request: pull_request:
branches: [main] branches: [main, master]
concurrency: concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }} group: ci-${{ github.workflow }}-${{ github.ref }}
@ -56,39 +56,37 @@ jobs:
- name: Sync dev dependencies - name: Sync dev dependencies
run: uv sync --group dev --frozen run: uv sync --group dev --frozen
- name: Run unit tests - name: Run unit tests with coverage
run: uv run pytest -m "not integration" 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 # Integration tests.
# infrastructure startup issue is investigated. They can still be run locally test-integration:
# with `docker compose up -d` followed by `uv run pytest -m integration`. name: Integration tests
# test-integration: runs-on: ubuntu-latest
# name: Integration tests steps:
# runs-on: ubuntu-latest - uses: actions/checkout@v4
# steps:
# - uses: actions/checkout@v4 - name: Setup uv
# uses: astral-sh/setup-uv@v8.3.2
# - name: Setup uv with:
# uses: astral-sh/setup-uv@v8.3.2 enable-cache: true
# with: cache-dependency-glob: uv.lock
# enable-cache: true
# cache-dependency-glob: uv.lock - name: Install Python
# run: uv python install
# - name: Install Python
# run: uv python install - name: Sync dev dependencies
# run: uv sync --group dev --frozen
# - name: Sync dev dependencies
# run: uv sync --group dev --frozen - name: Start infrastructure (postgres/redis/rabbitmq/minio)
# run: docker compose up -d --wait
# - name: Start infrastructure (postgres/redis/rabbitmq/minio)
# run: docker compose up -d --wait - name: Run integration tests
# run: uv run pytest -m integration
# - name: Run integration tests
# run: uv run pytest -m integration - name: Teardown infrastructure
# if: always()
# - name: Teardown infrastructure run: docker compose down -v
# if: always()
# run: docker compose down -v
# Build and push service images on pushes to main. Set these repository secrets: # 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) # REGISTRY e.g. ghcr.io (or docker.io, your-private-registry.io)

View file

@ -1,5 +1,5 @@
# Local hooks run through `uv run` so they use the pinned tool versions # 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" minimum_pre_commit_version: "4.0.0"

View file

@ -44,6 +44,9 @@ test: ## Run all tests (unit + integration)
test-unit: ## Run unit tests only (fast) test-unit: ## Run unit tests only (fast)
uv run pytest -m "not integration" 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) test-integration: ## Run integration tests (needs docker infra)
uv run pytest -m integration uv run pytest -m integration
@ -206,7 +209,7 @@ shell-bot: ## Open shell inside bot container
clean: ## Remove containers, volumes, caches clean: ## Remove containers, volumes, caches
docker compose --profile services down -v docker compose --profile services down -v
docker compose down -v docker compose down -v
rm -rf .mypy_cache .pytest_cache .ruff_cache rm -rf .pytest_cache .ruff_cache
uv cache clean uv cache clean
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────

View file

@ -1,5 +1,6 @@
# Alembic config. The DB URL is resolved at runtime from DATABASE_URL env # 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] [alembic]
script_location = migrations script_location = migrations
prepend_sys_path = src prepend_sys_path = src

View file

@ -191,9 +191,9 @@ DealDocumentScreening/
│ │ ├── redis_client.py (async Redis client from redis_url) │ │ ├── redis_client.py (async Redis client from redis_url)
│ │ ├── db/ │ │ ├── db/
│ │ │ ├── __init__.py │ │ │ ├── __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) │ │ │ ├── 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/ │ │ ├── mq/
│ │ │ ├── __init__.py │ │ │ ├── __init__.py
│ │ │ ├── topology.py (exchange/queue/rk constants + declare_all()) │ │ │ ├── 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` Six core tables plus additive migrations. `status`/`queue`/`adapter`/`role`
columns are `TEXT + CHECK` (not Postgres enums) so migrations are additive — columns are `TEXT + CHECK` (not Postgres enums) so migrations are additive —
matches the convention noted in `IMPLEMENTATION_PLAN.md` §1.2. 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`): Migrations (hand-written, async `env.py`):
- `0001` initial schema: users/documents/reports/jobs/service_tokens/invoices - `0001` initial schema: users/documents/reports/jobs/service_tokens/invoices
- `0002` `api_keys` + `api_key_requests` (B2B) - `0002` `api_keys` + `api_key_requests` (B2B)
@ -616,7 +644,7 @@ CREATE TABLE prescreen_results (
error_message TEXT, error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0) 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_routing_idx ON prescreen_results (routing_decision);
CREATE INDEX prescreen_results_confidence_idx ON prescreen_results (confidence_score); 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 = minio, minio-init. All with healthchecks + volumes. Default profile =
infra only. infra only.
- `src/contract_check/core/`: config, logging, telemetry, sentry, metrics, - `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/ s3/minio_storage, llm/port, analysis/* (migrate extractor/chunker/checklist/
report_schema from prototype), credits, tokens. report_schema from prototype), credits, tokens.
- Alembic: init + initial migration (6 tables). - Alembic: init + initial migration (6 tables).

View file

@ -130,14 +130,13 @@ dev = [
"pytest-asyncio>=0.23", "pytest-asyncio>=0.23",
"respx>=0.21", "respx>=0.21",
"ruff>=0.5", "ruff>=0.5",
"mypy>=1.10",
"anyio>=4", "anyio>=4",
"aiosqlite>=0.20", "aiosqlite>=0.20",
"testcontainers[rabbitmq,postgres,minio]>=4",
"asgi-lifespan>=2.1.0", "asgi-lifespan>=2.1.0",
"pre-commit>=4.6.2", "pre-commit>=4.6.2",
"isort>=5.13", "isort>=5.13",
"ty>=0.0.72", "ty>=0.0.72",
"pytest-cov>=7.1.0",
] ]
[tool.ruff] [tool.ruff]
@ -169,3 +168,22 @@ pythonpath = ["."]
markers = [ markers = [
"integration: slow tests needing real Postgres/RabbitMQ/MinIO (deselect with '-m \"not integration\"')", "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

View file

@ -14,6 +14,7 @@ import sys
import uvicorn import uvicorn
from ..core.config import get_settings from ..core.config import get_settings
from ..core.db.repositories import ServiceTokenRepository
from ..core.db.session import create_session_factory from ..core.db.session import create_session_factory
from ..core.tokens import assert_adapter, generate_token, hash_token from ..core.tokens import assert_adapter, generate_token, hash_token
from .app import create_app 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() raw = token or generate_token()
factory = create_session_factory() factory = create_session_factory()
async with factory() as session: async with factory() as session:
from sqlalchemy import text repo = ServiceTokenRepository(session)
await repo.upsert(name=name, token_hash=hash_token(raw), adapter=adapter)
await session.execute(
text(
"INSERT INTO service_tokens (name, token_hash, adapter) "
"VALUES (:name, :hash, :adapter) "
"ON CONFLICT (name) DO UPDATE SET "
" token_hash = EXCLUDED.token_hash, "
" revoked = FALSE, "
" adapter = EXCLUDED.adapter"
),
{"name": name, "hash": hash_token(raw), "adapter": adapter},
)
await session.commit() await session.commit()
print(f"Token '{name}' ({adapter}) ready. Bearer:") print(f"Token '{name}' ({adapter}) ready. Bearer:")
print(raw) print(raw)

View file

@ -17,11 +17,11 @@ from dataclasses import dataclass
from uuid import UUID from uuid import UUID
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ...core.auth import AuthError, verify_access_token from ...core.auth import AuthError, verify_access_token
from ...core.config import get_settings from ...core.config import get_settings
from ...core.db.repositories import UserRepository
from ..deps import AsyncSessionDep from ..deps import AsyncSessionDep
# HttpOnly cookie carrying the admin access JWT. # HttpOnly cookie carrying the admin access JWT.
@ -76,22 +76,17 @@ async def resolve_admin(request: Request, session: AsyncSession) -> AdminUser |
except AuthError: except AuthError:
return None return None
result = await session.execute( user = await UserRepository(session).get_by_id(claims.sub)
text("SELECT id, telegram_id, email, role, is_active FROM users WHERE id = :u"), if user is None:
{"u": claims.sub},
)
row = result.first()
if row is None:
return None return None
user_id, telegram_id, email, role, is_active = row if not user.is_active or user.role != get_settings().admin_required_role:
if not is_active or role != get_settings().admin_required_role:
return None return None
return AdminUser( return AdminUser(
user_id=user_id, user_id=user.id,
telegram_id=int(telegram_id or 0), telegram_id=int(user.telegram_id or 0),
email=email, email=user.email,
role=role, role=user.role,
) )

View file

@ -11,10 +11,10 @@ from typing import Annotated
from fastapi import APIRouter, Form, Request, status from fastapi import APIRouter, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import text
from ...core.auth import create_access_token from ...core.auth import create_access_token
from ...core.config import get_settings from ...core.config import get_settings
from ...core.db.repositories import UserRepository
from ...core.logging import get_logger from ...core.logging import get_logger
from ...core.security.passwords import verify_password from ...core.security.passwords import verify_password
from ..deps import AsyncSessionDep from ..deps import AsyncSessionDep
@ -75,34 +75,26 @@ async def login(
) -> RedirectResponse: ) -> RedirectResponse:
"""Verify email/password + admin role, set the cookie, redirect to users.""" """Verify email/password + admin role, set the cookie, redirect to users."""
email_norm = email.lower().strip() email_norm = email.lower().strip()
result = await session.execute( user = await UserRepository(session).get_by_email(email_norm)
text(
"SELECT id, telegram_id, email, password_hash, role, is_active "
"FROM users WHERE email = :e"
),
{"e": email_norm},
)
row = result.first()
invalid = RedirectResponse( invalid = RedirectResponse(
url="/admin/login?reason=invalid", status_code=status.HTTP_303_SEE_OTHER url="/admin/login?reason=invalid", status_code=status.HTTP_303_SEE_OTHER
) )
if row is None: if user is None:
return invalid return invalid
user_id, telegram_id, _email, password_hash, role, is_active = row if not user.password_hash or not verify_password(password, user.password_hash):
if not password_hash or not verify_password(password, password_hash):
return invalid return invalid
if not is_active: if not user.is_active:
return RedirectResponse( return RedirectResponse(
url="/admin/login?reason=disabled", status_code=status.HTTP_303_SEE_OTHER url="/admin/login?reason=disabled", status_code=status.HTTP_303_SEE_OTHER
) )
if role != get_settings().admin_required_role: if user.role != get_settings().admin_required_role:
log.warning("admin_login_forbidden", user_id=str(user_id), role=role) log.warning("admin_login_forbidden", user_id=str(user.id), role=user.role)
return RedirectResponse( return RedirectResponse(
url="/admin/login?reason=forbidden", status_code=status.HTTP_303_SEE_OTHER 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 = RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie( response.set_cookie(
key=ADMIN_COOKIE, key=ADMIN_COOKIE,
@ -112,7 +104,7 @@ async def login(
samesite="lax", samesite="lax",
secure=get_settings().env == "prod", 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 return response

View file

@ -15,11 +15,11 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ...core.config import get_settings from ...core.config import get_settings
from ...core.db.enums import USER_ROLE_USER, USER_ROLES 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.logging import get_logger
from ...core.security.passwords import hash_password from ...core.security.passwords import hash_password
from ..deps import AsyncSessionDep from ..deps import AsyncSessionDep
@ -36,54 +36,30 @@ router = APIRouter(
PAGE_SIZE = 25 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: async def _fetch_user(session: AsyncSession, user_id: uuid.UUID) -> dict[str, Any] | None:
result = await session.execute( user = await UserRepository(session).get_by_id(user_id)
text(f"SELECT {_USER_COLUMNS} FROM users WHERE id = :u"), if user is None:
{"u": user_id},
)
row = result.first()
if row is None:
return 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]: async def _fetch_user_stats(session: AsyncSession, user_id: uuid.UUID) -> dict[str, int]:
"""Aggregate counts shown on the detail page.""" """Aggregate counts shown on the detail page."""
result = await session.execute( return await DocumentRepository(session).stats_for_user(user_id)
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]),
}
@router.get("", response_class=HTMLResponse, include_in_schema=False) @router.get("", response_class=HTMLResponse, include_in_schema=False)
@ -97,44 +73,27 @@ async def list_users(
page = max(page, 1) page = max(page, 1)
offset = (page - 1) * PAGE_SIZE offset = (page - 1) * PAGE_SIZE
term = q.strip() term = q.strip()
filter_sql = ""
filter_params: dict[str, Any] = {}
# Optional admin filters via query string (no URL params in form; keep simple). # Optional admin filters via query string (no URL params in form; keep simple).
only_telegram = request.query_params.get("only_telegram") == "1" only_telegram = request.query_params.get("only_telegram") == "1"
unverified = request.query_params.get("unverified") == "1" unverified = request.query_params.get("unverified") == "1"
if only_telegram: repo = UserRepository(session)
filter_sql += " AND telegram_id IS NOT NULL" users_orm = await repo.list_users(
if unverified: search=term or None,
filter_sql += " AND telegram_verified = FALSE" has_telegram=only_telegram or None,
telegram_verified=False if unverified else None,
base_where = "WHERE (email ILIKE :q OR telegram_id::text ILIKE :q)" if term else "WHERE TRUE" limit=PAGE_SIZE,
base_where += filter_sql offset=offset,
like = f"%{term}%" )
filter_params = {"q": like} if term else {} total = await repo.count_users(
search=term or None,
rows = ( has_telegram=only_telegram or None,
await session.execute( telegram_verified=False if unverified else None,
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()
) )
pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) 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( return templates.TemplateResponse(
request, request,
"users_list.html", "users_list.html",
@ -236,10 +195,8 @@ async def create_user(
tg, tg,
) )
dup = ( repo = UserRepository(session)
await session.execute(text("SELECT 1 FROM users WHERE email = :e"), {"e": email_norm}) if await repo.email_exists(email_norm):
).first()
if dup is not None:
return _render_new_form( return _render_new_form(
request, request,
"Пользователь с таким email уже существует.", "Пользователь с таким email уже существует.",
@ -248,44 +205,32 @@ async def create_user(
credits_left, credits_left,
tg, tg,
) )
if tg_id is not None: if tg_id is not None and await repo.telegram_id_exists(tg_id):
dup_tg = ( return _render_new_form(
await session.execute(text("SELECT 1 FROM users WHERE telegram_id = :t"), {"t": tg_id}) request,
).first() "Этот Telegram ID уже привязан к другому пользователю.",
if dup_tg is not None: email_norm,
return _render_new_form( role,
request, credits_left,
"Этот Telegram ID уже привязан к другому пользователю.", tg,
email_norm, )
role,
credits_left,
tg,
)
# ── insert ── # ── insert ──
hashed = hash_password(password) hashed = hash_password(password)
result = await session.execute( new_user = await repo.create_email_user(
text( email=email_norm,
"INSERT INTO users " name=None,
"(email, password_hash, role, credits_left, telegram_id, is_active) " password_hash=hashed,
"VALUES (:e, :p, :r, :c, :t, :a) " credits_left=credits,
f"RETURNING {_USER_COLUMNS}" role=role,
), telegram_id=tg_id,
{ is_active=is_active == "on",
"e": email_norm,
"p": hashed,
"r": role,
"c": credits,
"t": tg_id,
"a": is_active == "on",
},
) )
row = result.first()
assert row is not None # RETURNING always yields the inserted row
await session.commit() await session.commit()
new_id = row[0] log.info("admin_user_created", user_id=str(new_user.id), email=email_norm, role=role)
log.info("admin_user_created", user_id=str(new_id), email=email_norm, role=role) return RedirectResponse(
return RedirectResponse(url=f"/admin/users/{new_id}", status_code=status.HTTP_303_SEE_OTHER) 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) @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.""" """Update role / active flag / credits. Returns the refreshed user card."""
if role not in USER_ROLES: if role not in USER_ROLES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid role") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid role")
credits = max(0, int(credits_left)) repo = UserRepository(session)
await session.execute( await repo.set_role(user_id, role)
text("UPDATE users SET role = :r, is_active = :a, credits_left = :c WHERE id = :u"), await repo.set_active(user_id, is_active == "on")
{"r": role, "a": is_active == "on", "c": credits, "u": user_id}, await repo.adjust_credits(user_id, max(0, int(credits_left)))
)
await session.commit() await session.commit()
user = await _fetch_user(session, user_id) user = await _fetch_user(session, user_id)
@ -349,10 +293,8 @@ async def toggle_active(
session: AsyncSessionDep, session: AsyncSessionDep,
_: Annotated[None, HtmxGuard], _: Annotated[None, HtmxGuard],
) -> HTMLResponse: ) -> HTMLResponse:
await session.execute( repo = UserRepository(session)
text("UPDATE users SET is_active = NOT is_active WHERE id = :u"), await repo.toggle_active(user_id)
{"u": user_id},
)
await session.commit() await session.commit()
user = await _fetch_user(session, user_id) user = await _fetch_user(session, user_id)
assert user is not None assert user is not None
@ -375,10 +317,7 @@ async def verify_telegram(
_: Annotated[None, HtmxGuard], _: Annotated[None, HtmxGuard],
) -> HTMLResponse: ) -> HTMLResponse:
"""Mark the user's Telegram identity as verified (admin review).""" """Mark the user's Telegram identity as verified (admin review)."""
await session.execute( await UserRepository(session).verify_telegram(user_id)
text("UPDATE users SET telegram_verified = TRUE WHERE id = :u"),
{"u": user_id},
)
await session.commit() await session.commit()
user = await _fetch_user(session, user_id) user = await _fetch_user(session, user_id)
assert user is not None assert user is not None
@ -401,10 +340,7 @@ async def adjust_credits(
) -> HTMLResponse: ) -> HTMLResponse:
"""Bump credits by ``delta`` (clamped at 0). HTMX button target.""" """Bump credits by ``delta`` (clamped at 0). HTMX button target."""
if delta != 0: if delta != 0:
await session.execute( await UserRepository(session).adjust_credits(user_id, int(delta))
text("UPDATE users SET credits_left = greatest(0, credits_left + :d) WHERE id = :u"),
{"d": int(delta), "u": user_id},
)
await session.commit() await session.commit()
user = await _fetch_user(session, user_id) user = await _fetch_user(session, user_id)
assert user is not None assert user is not None

View file

@ -14,6 +14,7 @@ from fastapi import FastAPI
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from ..core.config import get_settings from ..core.config import get_settings
from ..core.db.repositories import UserRepository
from ..core.db.session import dispose_engine from ..core.db.session import dispose_engine
from ..core.llm import port as llm_port # noqa: F401 — package loaded from ..core.llm import port as llm_port # noqa: F401 — package loaded
from ..core.logging import bind_context, configure_logging, get_logger 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 Uses a one-off DB session because the FastAPI dependency chain is not
available during lifespan setup. Requires web auth (Redis) to be available. 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.db.session import create_session_factory
from ..core.security.passwords import hash_password 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() factory = create_session_factory()
async with factory() as session: async with factory() as session:
existing = await session.execute( repo = UserRepository(session)
text("SELECT id FROM users WHERE email = :e"), existing = await repo.get_by_email(settings.admin_default_email.lower().strip())
{"e": settings.admin_default_email.lower().strip()}, if existing is None:
)
if existing.first() is None:
user = await create_email_user( user = await create_email_user(
session, session,
email=settings.admin_default_email.lower().strip(), email=settings.admin_default_email.lower().strip(),
name="Administrator", name="Administrator",
password_hash=hash_password(settings.admin_default_password), password_hash=hash_password(settings.admin_default_password),
) )
await session.execute( await repo.set_role(user.id, settings.admin_required_role)
text("UPDATE users SET role = :r WHERE id = :u"),
{"r": settings.admin_required_role, "u": user.id},
)
await session.commit() await session.commit()
logger.info( logger.info(
"default_admin_created", "default_admin_created",

View file

@ -2,14 +2,12 @@
from __future__ import annotations from __future__ import annotations
import datetime as dt
import json
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Annotated, Any from typing import Annotated, Any
from uuid import UUID from uuid import UUID
from fastapi import Depends, Header, HTTPException, Request from fastapi import Depends, Header, HTTPException, Request
from sqlalchemy import select, text from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..core.api_keys import hash_api_key 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.auth_refresh import RefreshTokenStore
from ..core.config import get_settings from ..core.config import get_settings
from ..core.db.models import PasskeyCredential, User 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.db.session import get_session
from ..core.logging import get_logger from ..core.logging import get_logger
from ..core.mq.publisher import Publisher from ..core.mq.publisher import Publisher
@ -118,18 +123,12 @@ async def require_service_token(
raw = authorization[7:].strip() raw = authorization[7:].strip()
token_hash = hash_token(raw) token_hash = hash_token(raw)
result = await session.execute( repo = ServiceTokenRepository(session)
text("SELECT id FROM service_tokens WHERE token_hash = :h AND revoked = FALSE"), token_id = await repo.get_id_by_hash(token_hash)
{"h": token_hash}, if token_id is None:
)
row = result.first()
if row is None:
raise HTTPException(status_code=401, detail="Invalid or revoked token") raise HTTPException(status_code=401, detail="Invalid or revoked token")
await session.execute( await repo.bump_last_used(token_id)
text("UPDATE service_tokens SET last_used_at = now() WHERE id = :id"),
{"id": row[0]},
)
await session.commit() 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 creation stores the profile snapshot, verification flag, and binding time.
On existing row optionally refreshes the profile snapshot. On existing row optionally refreshes the profile snapshot.
""" """
result = await session.execute( repo = UserRepository(session)
text( user = await repo.get_by_telegram_id(telegram_id)
"SELECT id, telegram_id, email, password_hash, is_active, " if user is not None:
"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.
if profile: if profile:
await session.execute( await repo.update_telegram_profile(user.id, profile=profile, verified=verified)
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 session.commit() await session.commit()
return User( user.telegram_verified = verified
id=row[0], return user
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],
)
bound_at = dt.datetime.now(tz=dt.UTC) return await repo.create_telegram_user(telegram_id, profile=profile, verified=verified)
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],
)
async def get_or_create_user_by_id(session: AsyncSession, user_id: UUID) -> User | None: 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.""" """Fetch an existing user by UUID. Returns None if not found."""
result = await session.execute( user = await UserRepository(session).get_by_id(user_id)
text("SELECT id, telegram_id, created_at, credits_left FROM users WHERE id = :u"), if user is None:
{"u": user_id},
)
row = result.first()
if row is None:
return 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( 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. Raises HTTPException 409 if the telegram_id is already bound to another user.
""" """
dup = await session.execute( repo = UserRepository(session)
text("SELECT id FROM users WHERE telegram_id = :t AND id != :u"), if await repo.telegram_id_exists_for_other_user(telegram_id, user_id):
{"t": telegram_id, "u": user_id},
)
if dup.first() is not None:
raise HTTPException(status_code=409, detail="Telegram id already bound to another account") raise HTTPException(status_code=409, detail="Telegram id already bound to another account")
await session.execute( await repo.bind_telegram(user_id, telegram_id)
text("UPDATE users SET telegram_id = :t, telegram_bound_at = now() WHERE id = :u"),
{"t": telegram_id, "u": user_id},
)
await session.commit() await session.commit()
async def set_user_password(session: AsyncSession, user_id: UUID, password: str) -> None: 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).""" """Set/rotate a web password for a user (Telegram -> web UI access)."""
hashed = hash_password(password) hashed = hash_password(password)
await session.execute( await UserRepository(session).set_password(user_id, hashed)
text("UPDATE users SET password_hash = :p WHERE id = :u"),
{"p": hashed, "u": user_id},
)
await session.commit() await session.commit()
async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None: 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.""" """Fetch a user by email (case-sensitive — normalize upstream). Returns None if not found."""
result = await session.execute( return await UserRepository(session).get_by_email(email)
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],
)
async def fetch_passkey_credentials_for_user( async def fetch_passkey_credentials_for_user(
session: AsyncSession, user_id: UUID session: AsyncSession, user_id: UUID
) -> list[PasskeyCredential]: ) -> list[PasskeyCredential]:
"""All passkey credentials of a user, newest first.""" """All passkey credentials of a user, newest first."""
result = await session.execute( return await PasskeyRepository(session).list_for_user(user_id)
select(PasskeyCredential)
.where(PasskeyCredential.user_id == user_id)
.order_by(PasskeyCredential.created_at.desc())
)
return list(result.scalars().all())
async def fetch_passkey_by_credential_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: async def fetch_user_by_id_full(session: AsyncSession, user_id: UUID) -> User | None:
"""Fetch a user by UUID including web-auth columns.""" """Fetch a user by UUID including web-auth columns."""
result = await session.execute( return await UserRepository(session).get_by_id(user_id)
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],
)
async def create_email_user( async def create_email_user(
session: AsyncSession, *, email: str, name: str | None, password_hash: str session: AsyncSession, *, email: str, name: str | None, password_hash: str
) -> User: ) -> User:
"""Insert a new email/password user with 0 credits and return it.""" """Insert a new email/password user with 0 credits and return it."""
result = await session.execute( return await UserRepository(session).create_email_user(
text( email=email, name=name, password_hash=password_hash
"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],
) )
@ -364,18 +250,6 @@ class ApiKeyAuth:
self.rate_limit_rps = rate_limit_rps 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( async def require_api_key(
session: AsyncSessionDep, session: AsyncSessionDep,
rate_limiter: RateLimiterDep, rate_limiter: RateLimiterDep,
@ -390,31 +264,24 @@ async def require_api_key(
raise HTTPException(status_code=401, detail="Missing X-API-Key header") raise HTTPException(status_code=401, detail="Missing X-API-Key header")
key_hash = hash_api_key(x_api_key) key_hash = hash_api_key(x_api_key)
result = await session.execute( repo = ApiKeyRepository(session)
text( key = await repo.get_by_hash(key_hash)
"SELECT id, user_id, rate_limit_rps, monthly_quota, monthly_used, revoked " if key is None:
"FROM api_keys "
"WHERE key_hash = :h"
),
{"h": key_hash},
)
row = result.first()
if row is None:
raise HTTPException(status_code=401, detail="Invalid API key") raise HTTPException(status_code=401, detail="Invalid API key")
api_key_id, user_id, rate_limit_rps, monthly_quota, monthly_used, revoked = row if key.revoked:
if revoked:
raise HTTPException(status_code=401, detail="Revoked API key") 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. # 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): if monthly_quota is not None and int(monthly_used) >= int(monthly_quota):
raise HTTPException(status_code=429, detail="Monthly quota exceeded") raise HTTPException(status_code=429, detail="Monthly quota exceeded")
# Apply token-bucket rate limit per key id. # Apply token-bucket rate limit per key id.
limit = int(rate_limit_rps or get_settings().b2b_default_rate_limit_rps) limit = int(key.rate_limit_rps or get_settings().b2b_default_rate_limit_rps)
rl_result = await rate_limiter.allow(f"rate_limit:{api_key_id}", limit) rl_result = await rate_limiter.allow(f"rate_limit:{key.id}", limit)
if not rl_result.allowed: if not rl_result.allowed:
retry_after = max(1, int(rl_result.retry_after_sec or 1)) retry_after = max(1, int(rl_result.retry_after_sec or 1))
raise HTTPException( raise HTTPException(
@ -423,13 +290,10 @@ async def require_api_key(
headers={"Retry-After": str(retry_after)}, headers={"Retry-After": str(retry_after)},
) )
await session.execute( await repo.bump_last_used(key.id)
text("UPDATE api_keys SET last_used_at = now() WHERE id = :id"),
{"id": api_key_id},
)
await session.commit() 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)] 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( async def fetch_document_status_for_user(
session: AsyncSession, document_id: UUID, user_id: UUID 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).""" """Fetch document + report scoped to a specific user (B2B API)."""
result = await session.execute( status = await DocumentRepository(session).get_with_report_by_id_for_user(document_id, user_id)
text( if status is None:
"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 None
return { return {
"id": row[0], "id": status.id,
"status": row[1], "status": status.status,
"stage": row[2], "stage": status.stage,
"filename": row[3], "filename": status.filename,
"created_at": row[4], "created_at": status.created_at,
"markdown": row[5], "markdown": status.markdown,
"content_json": row[6], "content_json": status.content_json,
"model_used": row[7], "model_used": status.model_used,
"prompt_tokens": row[8], "prompt_tokens": status.prompt_tokens,
"eval_tokens": row[9], "eval_tokens": status.eval_tokens,
"latency_ms": row[10], "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, # Ensure the user still exists (defense in depth: tokens are stateless,
# but a deleted user should not be able to use them). # but a deleted user should not be able to use them).
result = await session.execute( if not await UserRepository(session).exists(claims.sub):
text("SELECT id FROM users WHERE id = :u"),
{"u": claims.sub},
)
if result.first() is None:
raise HTTPException(status_code=401, detail="User not found") raise HTTPException(status_code=401, detail="User not found")
return CurrentUser(user_id=claims.sub, telegram_id=claims.telegram_id) 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: async def get_credits(session: AsyncSession, user_id: UUID) -> int:
result = await session.execute( credits = await UserRepository(session).get_credits(user_id)
text("SELECT credits_left FROM users WHERE id = :u"), return credits if credits is not None else 0
{"u": user_id},
)
row = result.first()
if row is None:
return 0
return int(row[0])

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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=(
"<p>Вы запросили вход по ссылке.</p>"
f'<p><a href="{link}">Войти</a> '
f"(действует {settings.magic_link_ttl_minutes} мин., ссылка одноразовая)</p>"
"<p>Если вы не запрашивали вход — проигнорируйте это письмо.</p>"
),
)
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)

View file

@ -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")

View file

@ -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=(
"<p>Вы запросили сброс пароля.</p>"
f'<p><a href="{reset_link}">Задать новый пароль</a> '
f"(действует {settings.password_reset_ttl_minutes} мин.)</p>"
"<p>Если вы не запрашивали сброс — проигнорируйте это письмо.</p>"
),
)
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")

View file

@ -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}"

View file

@ -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"]}

View file

@ -11,10 +11,9 @@ import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, File, HTTPException, UploadFile, status 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.api_keys import generate_api_key, hash_api_key
from ...core.db.repositories import ApiKeyRepository
from ...core.logging import get_logger from ...core.logging import get_logger
from ..deps import ( from ..deps import (
ApiKeyAuthDep, ApiKeyAuthDep,
@ -24,6 +23,17 @@ from ..deps import (
StorageDep, StorageDep,
fetch_document_status_for_user, fetch_document_status_for_user,
) )
from ..schemas import (
ApiKeyCreatedResponse,
ApiKeyResponse,
ApiKeyUsageDetailResponse,
B2BUsageResponse,
CreateApiKeyRequest,
DocumentUploadResponse,
ReportInProgressResponse,
ReportResponse,
RevokeApiKeyResponse,
)
from ..services import upload_and_enqueue from ..services import upload_and_enqueue
log = get_logger(__name__) log = get_logger(__name__)
@ -31,14 +41,16 @@ log = get_logger(__name__)
router = APIRouter(tags=["b2b"]) 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( async def analyze_document(
auth: ApiKeyAuthDep, auth: ApiKeyAuthDep,
session: AsyncSessionDep, session: AsyncSessionDep,
storage: StorageDep, storage: StorageDep,
publisher: PublisherDep, publisher: PublisherDep,
file: Annotated[UploadFile, File()], file: Annotated[UploadFile, File()],
) -> dict[str, object]: ) -> DocumentUploadResponse:
"""Upload a document for analysis using a B2B API key. """Upload a document for analysis using a B2B API key.
Reserves one credit from the key owner's account, stores the file, enqueues 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. # Record usage for this key.
try: try:
await session.execute( key_repo = ApiKeyRepository(session)
text("INSERT INTO api_key_requests (api_key_id, document_id) VALUES (:k, :d)"), await key_repo.record_request(auth.api_key_id, uuid.UUID(str(result.document_id)))
{"k": auth.api_key_id, "d": uuid.UUID(str(result["document_id"]))}, await key_repo.bump_monthly_used(auth.api_key_id)
)
await session.execute(
text("UPDATE api_keys SET monthly_used = monthly_used + 1 WHERE id = :k"),
{"k": auth.api_key_id},
)
await session.commit() await session.commit()
except Exception as exc: except Exception as exc:
log.error( log.error(
"api_key_request_log_failed", "api_key_request_log_failed",
api_key_id=str(auth.api_key_id), api_key_id=str(auth.api_key_id),
document_id=result["document_id"], document_id=result.document_id,
error=str(exc), error=str(exc),
) )
# Usage logging is best-effort; do not fail the upload. # Usage logging is best-effort; do not fail the upload.
@ -69,12 +76,14 @@ async def analyze_document(
return result 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( async def get_b2b_report(
auth: ApiKeyAuthDep, auth: ApiKeyAuthDep,
session: AsyncSessionDep, session: AsyncSessionDep,
document_id: uuid.UUID, document_id: uuid.UUID,
) -> dict[str, object]: ) -> ReportResponse | ReportInProgressResponse:
"""Poll for an analysis report scoped to the API key owner.""" """Poll for an analysis report scoped to the API key owner."""
row = await fetch_document_status_for_user(session, document_id, auth.user_id) row = await fetch_document_status_for_user(session, document_id, auth.user_id)
if row is None: if row is None:
@ -82,83 +91,58 @@ async def get_b2b_report(
doc_status = row["status"] doc_status = row["status"]
if doc_status != "done": if doc_status != "done":
return { return ReportInProgressResponse(
"document_id": str(document_id), document_id=str(document_id),
"status": doc_status, status=doc_status,
"stage": row["stage"], stage=row["stage"],
} )
return { return ReportResponse(
"document_id": str(document_id), document_id=str(document_id),
"status": doc_status, status=doc_status,
"filename": row["filename"], filename=row["filename"],
"markdown": row["markdown"], markdown=row["markdown"],
"findings": row["content_json"], findings=row["content_json"],
"model_used": row["model_used"], model_used=row["model_used"],
"prompt_tokens": row["prompt_tokens"], prompt_tokens=row["prompt_tokens"],
"eval_tokens": row["eval_tokens"], eval_tokens=row["eval_tokens"],
"latency_ms": row["latency_ms"], latency_ms=row["latency_ms"],
} )
@router.get("/api/v1/b2b/usage") @router.get("/api/v1/b2b/usage", response_model=B2BUsageResponse)
async def get_usage( async def get_usage(
auth: ApiKeyAuthDep, auth: ApiKeyAuthDep,
session: AsyncSessionDep, session: AsyncSessionDep,
) -> dict[str, object]: ) -> B2BUsageResponse:
"""Return current-month usage for the authenticating API key.""" """Return current-month usage for the authenticating API key."""
result = await session.execute( repo = ApiKeyRepository(session)
text("SELECT monthly_quota, monthly_used, resets_at FROM api_keys WHERE id = :k"), key = await repo.get_by_id(auth.api_key_id)
{"k": auth.api_key_id}, assert key is not None
)
row = result.first()
assert row is not None
quota, used, resets_at = row _, monthly_used = await repo.get_monthly_quota_state(auth.api_key_id)
requests_result = await session.execute(
text(
"SELECT COUNT(*) FROM api_key_requests "
"WHERE api_key_id = :k AND created_at >= DATE_TRUNC('month', now())"
),
{"k": auth.api_key_id},
)
requests_this_month = int(requests_result.scalar_one())
return { return B2BUsageResponse(
"api_key_id": str(auth.api_key_id), api_key_id=str(auth.api_key_id),
"rate_limit_rps": auth.rate_limit_rps, rate_limit_rps=auth.rate_limit_rps,
"monthly_quota": quota, monthly_quota=key.monthly_quota,
"monthly_used": used, monthly_used=monthly_used,
"requests_this_month": requests_this_month, requests_this_month=await repo.count_requests_this_month(auth.api_key_id),
"resets_at": resets_at.isoformat() if resets_at else None, resets_at=key.resets_at.isoformat() if key.resets_at else None,
} )
# ── Key management (service-token auth + telegram_id) ──────────────────────── # ── Key management (service-token auth + telegram_id) ────────────────────────
class CreateApiKeyRequest(BaseModel): @router.post(
name: str "/api/v1/b2b/keys", response_model=ApiKeyCreatedResponse, status_code=status.HTTP_201_CREATED
rate_limit_rps: int | None = None )
monthly_quota: int | None = None
class ApiKeyResponse(BaseModel):
id: uuid.UUID
name: str
rate_limit_rps: int
monthly_quota: int | None
monthly_used: int
revoked: bool
created_at: str
@router.post("/api/v1/b2b/keys", status_code=status.HTTP_201_CREATED)
async def create_api_key( async def create_api_key(
session: AsyncSessionDep, session: AsyncSessionDep,
user: CurrentUserDep, user: CurrentUserDep,
body: CreateApiKeyRequest, body: CreateApiKeyRequest,
) -> dict[str, object]: ) -> ApiKeyCreatedResponse:
"""Create a new B2B API key for the authenticated user. """Create a new B2B API key for the authenticated user.
The raw key is returned **only once**; afterwards only its hash is stored. 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) key_hash = hash_api_key(raw_key)
try: try:
result = await session.execute( key = await ApiKeyRepository(session).create(
text( user_id=user.user_id,
"INSERT INTO api_keys (user_id, name, key_hash, rate_limit_rps, monthly_quota) " name=body.name,
"VALUES (:u, :n, :h, :r, :q) " key_hash=key_hash,
"RETURNING id, name, rate_limit_rps, monthly_quota, monthly_used, revoked, created_at" rate_limit_rps=rate_limit,
), monthly_quota=body.monthly_quota,
{
"u": user.user_id,
"n": body.name,
"h": key_hash,
"r": rate_limit,
"q": body.monthly_quota,
},
) )
await session.commit() await session.commit()
except Exception as exc: except Exception as exc:
log.error("api_key_create_failed", user_id=str(user.user_id), error=str(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 raise HTTPException(status_code=500, detail="failed to create API key") from exc
row = result.first() return ApiKeyCreatedResponse(
assert row is not None api_key=raw_key,
return { id=key.id,
"api_key": raw_key, name=key.name,
"id": str(row[0]), rate_limit_rps=key.rate_limit_rps,
"name": row[1], monthly_quota=key.monthly_quota,
"rate_limit_rps": row[2], monthly_used=key.monthly_used,
"monthly_quota": row[3], revoked=key.revoked,
"monthly_used": row[4], created_at=key.created_at.isoformat() if key.created_at else None,
"revoked": row[5], )
"created_at": row[6].isoformat() if row[6] else None,
}
@router.get("/api/v1/b2b/keys") @router.get("/api/v1/b2b/keys", response_model=list[ApiKeyResponse])
async def list_api_keys( async def list_api_keys(
session: AsyncSessionDep, session: AsyncSessionDep,
user: CurrentUserDep, user: CurrentUserDep,
) -> list[dict[str, object]]: ) -> list[ApiKeyResponse]:
"""List B2B API keys for the authenticated user.""" """List B2B API keys for the authenticated user."""
result = await session.execute( keys = await ApiKeyRepository(session).list_for_user(user.user_id)
text(
"SELECT id, name, rate_limit_rps, monthly_quota, monthly_used, revoked, created_at "
"FROM api_keys WHERE user_id = :u ORDER BY created_at DESC"
),
{"u": user.user_id},
)
return [ return [
{ ApiKeyResponse(
"id": str(row[0]), id=key.id,
"name": row[1], name=key.name,
"rate_limit_rps": row[2], rate_limit_rps=key.rate_limit_rps,
"monthly_quota": row[3], monthly_quota=key.monthly_quota,
"monthly_used": row[4], monthly_used=key.monthly_used,
"revoked": row[5], revoked=key.revoked,
"created_at": row[6].isoformat() if row[6] else None, created_at=key.created_at.isoformat() if key.created_at else None,
} )
for row in result.all() 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( async def revoke_api_key(
session: AsyncSessionDep, session: AsyncSessionDep,
user: CurrentUserDep, user: CurrentUserDep,
key_id: uuid.UUID, key_id: uuid.UUID,
) -> dict[str, object]: ) -> RevokeApiKeyResponse:
"""Revoke a B2B API key. Only keys owned by the user may be revoked.""" """Revoke a B2B API key. Only keys owned by the user may be revoked."""
result = await session.execute( key = await ApiKeyRepository(session).get_by_id(key_id)
text( if key is None or key.user_id != user.user_id or key.revoked:
"UPDATE api_keys SET revoked = TRUE "
"WHERE id = :k AND user_id = :u AND revoked = FALSE "
"RETURNING id"
),
{"k": key_id, "u": user.user_id},
)
await session.commit()
if result.first() is None:
raise HTTPException(status_code=404, detail="key not found or already revoked") 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( async def get_key_usage(
session: AsyncSessionDep, session: AsyncSessionDep,
user: CurrentUserDep, user: CurrentUserDep,
key_id: uuid.UUID, key_id: uuid.UUID,
) -> dict[str, object]: ) -> ApiKeyUsageDetailResponse:
"""Return per-month usage for a specific API key owned by the user.""" """Return per-month usage for a specific API key owned by the user."""
key_result = await session.execute( repo = ApiKeyRepository(session)
text( key = await repo.get_by_id(key_id)
"SELECT name, rate_limit_rps, monthly_quota, monthly_used, resets_at " if key is None or key.user_id != user.user_id:
"FROM api_keys WHERE id = :k AND user_id = :u"
),
{"k": key_id, "u": user.user_id},
)
key_row = key_result.first()
if key_row is None:
raise HTTPException(status_code=404, detail="key not found") raise HTTPException(status_code=404, detail="key not found")
monthly_result = await session.execute( monthly_result = await repo.get_usage_by_key(key_id)
text(
"SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) "
"FROM api_key_requests "
"WHERE api_key_id = :k "
"GROUP BY month ORDER BY month DESC"
),
{"k": key_id},
)
return { return ApiKeyUsageDetailResponse(
"key_id": str(key_id), key_id=str(key_id),
"name": key_row[0], name=key.name,
"rate_limit_rps": key_row[1], rate_limit_rps=key.rate_limit_rps,
"monthly_quota": key_row[2], monthly_quota=key.monthly_quota,
"monthly_used": key_row[3], monthly_used=key.monthly_used,
"resets_at": key_row[4].isoformat() if key_row[4] else None, resets_at=key.resets_at.isoformat() if key.resets_at else None,
"monthly_requests": [ monthly_requests=[
{"month": row[0].isoformat() if row[0] else None, "requests": int(row[1])} {"month": req.created_at.isoformat() if req.created_at else None, "requests": 1}
for row in monthly_result.all() for req in monthly_result
], ],
} )

View file

@ -8,19 +8,20 @@ from fastapi import APIRouter, File, UploadFile, status
from ...core.logging import get_logger from ...core.logging import get_logger
from ..deps import AsyncSessionDep, CurrentUserDep, PublisherDep, StorageDep from ..deps import AsyncSessionDep, CurrentUserDep, PublisherDep, StorageDep
from ..schemas import DocumentUploadResponse
from ..services import upload_and_enqueue from ..services import upload_and_enqueue
log = get_logger(__name__) 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( async def upload_document(
session: AsyncSessionDep, session: AsyncSessionDep,
storage: StorageDep, storage: StorageDep,
publisher: PublisherDep, publisher: PublisherDep,
user: CurrentUserDep, user: CurrentUserDep,
file: Annotated[UploadFile, File()], file: Annotated[UploadFile, File()],
) -> dict[str, object]: ) -> DocumentUploadResponse:
return await upload_and_enqueue(session, storage, publisher, user.user_id, file) return await upload_and_enqueue(session, storage, publisher, user.user_id, file)

View file

@ -18,6 +18,7 @@ async def healthz() -> dict[str, str]:
@router.get("/readyz") @router.get("/readyz")
async def readyz(session: AsyncSessionDep) -> dict[str, str]: async def readyz(session: AsyncSessionDep) -> dict[str, str]:
try: try:
# Deliberate raw SQL: lightweight connection/statement round-trip ping.
await session.execute(text("SELECT 1")) await session.execute(text("SELECT 1"))
except Exception as exc: except Exception as exc:
return {"status": "not_ready", "reason": f"db: {exc}"} return {"status": "not_ready", "reason": f"db: {exc}"}

View file

@ -3,9 +3,8 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, status from fastapi import APIRouter, status
from pydantic import BaseModel, Field
from sqlalchemy import text
from ...core.db.repositories import DocumentRepository
from ..deps import ( from ..deps import (
AsyncSessionDep, AsyncSessionDep,
CurrentUserDep, CurrentUserDep,
@ -13,30 +12,19 @@ from ..deps import (
get_credits, get_credits,
set_user_password, 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): @router.get("", response_model=MeResponse)
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)
async def me( async def me(
session: AsyncSessionDep, session: AsyncSessionDep,
user: CurrentUserDep, user: CurrentUserDep,
@ -48,7 +36,7 @@ async def me(
) )
@router.get("/api/v1/me/documents") @router.get("/documents")
async def list_my_documents( async def list_my_documents(
session: AsyncSessionDep, session: AsyncSessionDep,
user: CurrentUserDep, user: CurrentUserDep,
@ -60,32 +48,22 @@ async def list_my_documents(
if limit > 100: if limit > 100:
limit = 100 limit = 100
result = await session.execute( documents = await DocumentRepository(session).list_for_user(user.user_id, limit=limit, offset=0)
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()
return { return {
"documents": [ "documents": [
{ {
"document_id": str(row[0]), "document_id": str(doc.id),
"filename": row[1], "filename": doc.filename,
"status": row[2], "status": doc.status,
"stage": row[3], "stage": doc.stage,
"created_at": row[4].isoformat() if row[4] else None, "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( async def bind_telegram(
session: AsyncSessionDep, session: AsyncSessionDep,
user: CurrentUserDep, user: CurrentUserDep,
@ -96,7 +74,7 @@ async def bind_telegram(
return BindTelegramResponse(ok=True, telegram_id=body.telegram_id) 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( async def set_password(
session: AsyncSessionDep, session: AsyncSessionDep,
user: CurrentUserDep, user: CurrentUserDep,

View file

@ -7,36 +7,37 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from ..deps import AsyncSessionDep, CurrentUserDep, fetch_document_status_for_user 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( async def get_report(
session: AsyncSessionDep, session: AsyncSessionDep,
document_id: UUID, document_id: UUID,
user: CurrentUserDep, user: CurrentUserDep,
) -> dict[str, object]: ) -> ReportResponse | ReportInProgressResponse:
row = await fetch_document_status_for_user(session, document_id, user.user_id) row = await fetch_document_status_for_user(session, document_id, user.user_id)
if row is None: if row is None:
raise HTTPException(status_code=404, detail="report not found") raise HTTPException(status_code=404, detail="report not found")
status = row["status"] doc_status = row["status"]
if status != "done": if doc_status != "done":
return { return ReportInProgressResponse(
"document_id": str(document_id), document_id=str(document_id),
"status": status, status=doc_status,
"stage": row["stage"], stage=row["stage"],
} )
return { return ReportResponse(
"document_id": str(document_id), document_id=str(document_id),
"status": status, status=doc_status,
"filename": row["filename"], filename=row["filename"],
"markdown": row["markdown"], markdown=row["markdown"],
"findings": row["content_json"], findings=row["content_json"],
"model_used": row["model_used"], model_used=row["model_used"],
"prompt_tokens": row["prompt_tokens"], prompt_tokens=row["prompt_tokens"],
"eval_tokens": row["eval_tokens"], eval_tokens=row["eval_tokens"],
"latency_ms": row["latency_ms"], latency_ms=row["latency_ms"],
} )

View file

@ -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",
]

View file

@ -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)

View file

@ -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

View file

@ -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]

View file

@ -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]

View file

@ -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)

View file

@ -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"]

View file

@ -6,15 +6,17 @@ import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import text
from ..core.analysis.extractor import SUPPORTED_SUFFIXES
from ..core.credits import reserve_credit 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.logging import get_logger, new_correlation_id
from ..core.metrics import credits_reserved, documents_uploaded from ..core.metrics import credits_reserved, documents_uploaded
from ..core.mq.messages import DocumentUploaded from ..core.mq.messages import DocumentUploaded
from ..core.mq.topology import RK_EXTRACT from ..core.mq.topology import RK_EXTRACT
from ..core.s3 import original_key from ..core.s3 import original_key
from .schemas import DocumentUploadResponse
if TYPE_CHECKING: if TYPE_CHECKING:
from fastapi import UploadFile from fastapi import UploadFile
@ -39,7 +41,7 @@ async def upload_and_enqueue(
publisher: Publisher, publisher: Publisher,
user_id: uuid.UUID, user_id: uuid.UUID,
file: UploadFile, file: UploadFile,
) -> dict[str, object]: ) -> DocumentUploadResponse:
"""Reserve credit, store file in MinIO, create document/job rows, publish to queue. """Reserve credit, store file in MinIO, create document/job rows, publish to queue.
Returns 202 payload: {document_id, correlation_id, credits_left}. 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)}", 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): if not await reserve_credit(session, user_id):
raise HTTPException(status_code=402, detail="no credits available") raise HTTPException(status_code=402, detail="no credits available")
await session.commit() await session.commit()
@ -75,42 +81,30 @@ async def upload_and_enqueue(
raise raise
except Exception as exc: except Exception as exc:
log.error("s3_upload_failed", document_id=str(document_id), error=str(exc)) log.error("s3_upload_failed", document_id=str(document_id), error=str(exc))
await session.execute( await credits_repo.adjust(user_id, 1)
text("UPDATE users SET credits_left = credits_left + 1 WHERE id = :u"),
{"u": user_id},
)
await session.commit() await session.commit()
raise HTTPException(status_code=500, detail="failed to store document") from exc raise HTTPException(status_code=500, detail="failed to store document") from exc
try: try:
await session.execute( await doc_repo.create(
text( document_id=document_id,
"INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) " user_id=user_id,
"VALUES (:id, :uid, :s3, :fn, :mime, :bytes, 'queued')" s3_key=s3_key,
), filename=file.filename or "document",
{ mime=content_type,
"id": document_id, bytes_=len(data),
"uid": user_id, status="queued",
"s3": s3_key,
"fn": file.filename,
"mime": content_type,
"bytes": len(data),
},
) )
await session.execute( await job_repo.create(
text( document_id=document_id,
"INSERT INTO jobs (document_id, correlation_id, queue, status) " correlation_id=uuid.UUID(correlation_id),
"VALUES (:did, :cid, 'extract', 'pending')" queue="extract",
), status="pending",
{"did": document_id, "cid": correlation_id},
) )
await session.commit() await session.commit()
except Exception as exc: except Exception as exc:
log.error("db_enqueue_failed", document_id=str(document_id), error=str(exc)) log.error("db_enqueue_failed", document_id=str(document_id), error=str(exc))
await session.execute( await credits_repo.adjust(user_id, 1)
text("UPDATE users SET credits_left = credits_left + 1 WHERE id = :u"),
{"u": user_id},
)
await session.commit() await session.commit()
raise HTTPException(status_code=500, detail="failed to enqueue document") from exc raise HTTPException(status_code=500, detail="failed to enqueue document") from exc
@ -127,23 +121,14 @@ async def upload_and_enqueue(
documents_uploaded.inc() documents_uploaded.inc()
except Exception as exc: except Exception as exc:
log.error("mq_publish_failed", document_id=str(document_id), error=str(exc)) log.error("mq_publish_failed", document_id=str(document_id), error=str(exc))
await session.execute( await credits_repo.adjust(user_id, 1)
text("UPDATE users SET credits_left = credits_left + 1 WHERE id = :u"), await doc_repo.mark_failed(document_id, stage="publish_failed")
{"u": user_id},
)
await session.execute(
text("UPDATE documents SET status = 'failed', stage = 'publish_failed' WHERE id = :id"),
{"id": document_id},
)
await session.commit() await session.commit()
raise HTTPException(status_code=500, detail="failed to publish job") from exc raise HTTPException(status_code=500, detail="failed to publish job") from exc
credits = await session.execute( credits_left = await credits_repo.get_balance(user_id)
text("SELECT credits_left FROM users WHERE id = :u"), return DocumentUploadResponse(
{"u": user_id}, 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()),
}

View file

@ -21,6 +21,7 @@ from aiogram import Bot, F, Router
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import BufferedInputFile, Document, Message, User 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.logging import get_logger, new_correlation_id
from ..core.rate_limit import RateLimiter from ..core.rate_limit import RateLimiter
from .client import ( from .client import (
@ -36,8 +37,6 @@ from .config import BotSettings
log = get_logger(__name__) log = get_logger(__name__)
router = Router(name="contract-check-bot") router = Router(name="contract-check-bot")
_SUPPORTED_SUFFIXES = (".pdf", ".docx")
_CONTENT_TYPES = { _CONTENT_TYPES = {
".pdf": "application/pdf", ".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",

View file

@ -10,12 +10,11 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from ..extraction.formats import SUPPORTED_SUFFIXES
from ..logging import get_logger from ..logging import get_logger
log = get_logger(__name__) log = get_logger(__name__)
SUPPORTED_SUFFIXES = {".pdf", ".docx"}
class ExtractionError(Exception): class ExtractionError(Exception):
"""Файл пуст, бит или формат не поддерживается.""" """Файл пуст, бит или формат не поддерживается."""

View file

@ -6,7 +6,6 @@ Just like service tokens, the raw key is shown only once; we store SHA-256.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import hmac
import secrets import secrets
@ -16,11 +15,5 @@ def generate_api_key() -> str:
def hash_api_key(key: str) -> 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() 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)

View file

@ -199,12 +199,13 @@ class Settings(BaseSettings):
chunk_size_chars: int = 10000 chunk_size_chars: int = 10000
# --- prescreen --- # --- prescreen ---
prescreen_enabled: bool = False # Defaults mirror .env.example (the documented production intent).
prescreen_confidence_threshold: float = 0.65 prescreen_enabled: bool = True
prescreen_high_value_threshold: float = 500_000.0 prescreen_confidence_threshold: float = 0.75
prescreen_high_value_threshold: float = 100_000.0
prescreen_auto_approve: bool = False prescreen_auto_approve: bool = False
prescreen_llm_fallback_enabled: bool = True prescreen_llm_fallback_enabled: bool = False
prescreen_llm_fallback_threshold: float = 0.55 prescreen_llm_fallback_threshold: float = 0.75
prescreen_llm_max_chars: int = 20_000 prescreen_llm_max_chars: int = 20_000
@property @property

View file

@ -1,5 +1,9 @@
"""Credits & refund policy — billing invariants. """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 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 api, atomically (never below zero). Refund is sacred: idempotent via the
`documents.refunded` flag (a retried/DLQ message can never double-refund). `documents.refunded` flag (a retried/DLQ message can never double-refund).
@ -14,40 +18,26 @@ from __future__ import annotations
import uuid import uuid
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from .db.enums import FailureClass, RefundPolicyLike 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"}) __all__ = [
"reserve_credit",
"refund_credit",
def should_refund(failure_class: FailureClass | str, policy: RefundPolicyLike) -> bool: "should_refund",
"""Pure policy decision: would this failure be refunded under `policy`? "NON_REFUNDABLE_INFRA_ONLY",
]
Under `infra_only`, user-garbage `extraction_failed` is NOT refunded
(the user pays for undetectable garbage). Everything else is.
"""
if policy == "infra_only" and failure_class in NON_REFUNDABLE_INFRA_ONLY:
return False
return True
async def reserve_credit(session: AsyncSession, user_id: uuid.UUID) -> bool: async def reserve_credit(session: AsyncSession, user_id: uuid.UUID) -> bool:
"""Atomically decrement credits_left by 1. Returns False if none available. """Atomically decrement credits_left by 1. Returns False if none available."""
return await CreditsRepository(session).reserve(user_id)
Never lets the balance go negative: the `WHERE credits_left > 0` guard makes
concurrent reservations race-safe exactly one of N wins the row.
"""
result = await session.execute(
text(
"UPDATE users SET credits_left = credits_left - 1 "
"WHERE id = :u AND credits_left > 0 "
"RETURNING credits_left"
),
{"u": user_id},
)
return result.first() is not None
async def refund_credit( async def refund_credit(
@ -56,29 +46,5 @@ async def refund_credit(
failure_class: FailureClass | str, failure_class: FailureClass | str,
policy: RefundPolicyLike, policy: RefundPolicyLike,
) -> bool: ) -> bool:
"""Refund one credit for a failed document, once. Returns False if skipped. """Refund one credit for a failed document, once. Returns False if skipped."""
return await CreditsRepository(session).refund(document_id, failure_class, policy)
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

View file

@ -9,14 +9,25 @@ from __future__ import annotations
from typing import Literal from typing import Literal
# documents.status # documents.status
DocStatus = Literal["queued", "extracting", "ocr", "analyzing", "done", "failed"] DocStatus = Literal[
DOC_STATUSES: tuple[str, ...] = (
"queued", "queued",
"extracting", "extracting",
"prescreening",
"ocr", "ocr",
"analyzing", "analyzing",
"done", "done",
"failed", "failed",
"manual_review",
]
DOC_STATUSES: tuple[str, ...] = (
"queued",
"extracting",
"prescreening",
"ocr",
"analyzing",
"done",
"failed",
"manual_review",
) )
DOC_TERMINAL: tuple[str, ...] = ("done", "failed") 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") JOB_STATUSES: tuple[str, ...] = ("pending", "running", "retrying", "dlq", "done")
# jobs.queue # jobs.queue
QueueName = Literal["extract", "analyze"] QueueName = Literal["extract", "prescreen", "analyze"]
QUEUE_NAMES: tuple[str, ...] = ("extract", "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 # service_tokens.adapter
AdapterName = Literal["bot", "web", "cli"] AdapterName = Literal["bot", "web", "cli"]

View file

@ -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), Tables: users, documents, reports, jobs, service_tokens, invoices (stub),
passkey_credentials. See docs/ARCHITECTURE.md §7. UUIDs default to api_keys, api_key_requests, passkey_credentials, prescreen_results.
gen_random_uuid() server-side (built into Postgres 13+, no extension needed See docs/ARCHITECTURE.md §7. UUIDs default to gen_random_uuid() server-side
for pg16). (built into Postgres 13+, no extension needed for pg16).
""" """
from __future__ import annotations from __future__ import annotations
import datetime as dt import datetime as dt
import uuid import uuid
from decimal import Decimal
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
Boolean, Boolean,
CheckConstraint, CheckConstraint,
Date,
DateTime, DateTime,
ForeignKey, ForeignKey,
Index, Index,
Integer, Integer,
Numeric,
String, String,
Text, Text,
UniqueConstraint, UniqueConstraint,
@ -59,11 +62,21 @@ class User(Base):
role: Mapped[str] = mapped_column( role: Mapped[str] = mapped_column(
String, nullable=False, default="user", server_default=text("'user'") String, nullable=False, default="user", server_default=text("'user'")
) )
telegram_profile_json: Mapped[str | None] = mapped_column(Text) telegram_profile_json: Mapped[str | None] = mapped_column(
telegram_verified: Mapped[bool] = mapped_column( Text,
Boolean, nullable=False, default=True, server_default=text("true") 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( created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
@ -77,6 +90,9 @@ class User(Base):
name="users_identity_present", name="users_identity_present",
), ),
CheckConstraint("role IN ('user', 'admin')", name="users_role_check"), 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( documents: Mapped[list[Document]] = relationship(
@ -168,16 +184,20 @@ class Document(Base):
back_populates="document", cascade="all, delete-orphan", uselist=False back_populates="document", cascade="all, delete-orphan", uselist=False
) )
jobs: Mapped[list[Job]] = relationship(back_populates="document", cascade="all, delete-orphan") 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( api_key_requests: Mapped[list[ApiKeyRequest]] = relationship(
back_populates="document", cascade="all, delete-orphan" back_populates="document", cascade="all, delete-orphan"
) )
__table_args__ = ( __table_args__ = (
CheckConstraint( CheckConstraint(
"status IN ('queued','extracting','ocr','analyzing','done','failed')", "status IN ('queued','extracting','prescreening','ocr','analyzing'"
",'done','failed','manual_review')",
name="documents_status_check", 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"), Index("documents_status_idx", "status"),
) )
@ -202,6 +222,15 @@ class Report(Base):
prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
eval_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) 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( created_at: Mapped[dt.datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
@ -209,6 +238,74 @@ class Report(Base):
document: Mapped[Document] = relationship(back_populates="report") 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): class Job(Base):
__tablename__ = "jobs" __tablename__ = "jobs"
@ -247,11 +344,12 @@ class Job(Base):
document: Mapped[Document] = relationship(back_populates="jobs") document: Mapped[Document] = relationship(back_populates="jobs")
__table_args__ = ( __table_args__ = (
CheckConstraint("queue IN ('extract','analyze')", name="jobs_queue_check"), CheckConstraint("queue IN ('extract','prescreen','analyze')", name="jobs_queue_check"),
CheckConstraint( CheckConstraint(
"status IN ('pending','running','retrying','dlq','done')", "status IN ('pending','running','retrying','dlq','done')",
name="jobs_status_check", name="jobs_status_check",
), ),
UniqueConstraint("document_id", "queue", name="jobs_document_queue_unique"),
Index("jobs_correlation_idx", "correlation_id"), Index("jobs_correlation_idx", "correlation_id"),
Index("jobs_document_idx", "document_id"), Index("jobs_document_idx", "document_id"),
) )
@ -397,5 +495,5 @@ class Invoice(Base):
"status IN ('draft','pending','succeeded','cancelled','refunded')", "status IN ('draft','pending','succeeded','cancelled','refunded')",
name="invoices_status_check", name="invoices_status_check",
), ),
Index("invoices_user_idx", "user_id", "created_at"), Index("invoices_user_idx", "user_id", text("created_at DESC")),
) )

View file

@ -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",
]

View file

@ -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())

View file

@ -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

View file

@ -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),
}

View file

@ -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()]

View file

@ -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())

View file

@ -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()]

View file

@ -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())

View file

@ -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},
)

View file

@ -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],
)

View file

@ -13,7 +13,8 @@ from .adapters.ocr_tesseract import TesseractOcrExtractor
from .adapters.pdf_pymupdf import PyMuPDFExtractor from .adapters.pdf_pymupdf import PyMuPDFExtractor
from .adapters.rtf_striprtf import RtfExtractor from .adapters.rtf_striprtf import RtfExtractor
from .adapters.txt_chardet import TxtExtractor 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 ( from .port import (
MIN_TEXT_CHARS, MIN_TEXT_CHARS,
DocumentExtractor, DocumentExtractor,

View file

@ -24,22 +24,9 @@ from .adapters.ocr_tesseract import TesseractOcrExtractor
from .adapters.pdf_pymupdf import PyMuPDFExtractor from .adapters.pdf_pymupdf import PyMuPDFExtractor
from .adapters.rtf_striprtf import RtfExtractor from .adapters.rtf_striprtf import RtfExtractor
from .adapters.txt_chardet import TxtExtractor from .adapters.txt_chardet import TxtExtractor
from .formats import SUPPORTED_SUFFIXES
from .port import DocumentExtractor, UnsupportedFormatError 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. # Canonical format ids → suffix(es). `image` covers every raster suffix.
_SUFFIX_TO_FORMAT = { _SUFFIX_TO_FORMAT = {
".pdf": "pdf", ".pdf": "pdf",

View file

@ -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",
}

View file

@ -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",
]

View file

@ -9,7 +9,6 @@ dependency that uses these lives in `api/deps.py` (Step 2).
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import hmac
import secrets import secrets
from typing import cast from typing import cast
@ -26,12 +25,6 @@ def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest() 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: def is_valid_adapter(adapter: str) -> bool:
return adapter in ADAPTER_NAMES return adapter in ADAPTER_NAMES

View file

@ -12,17 +12,15 @@ in-process without spinning up a real RabbitMQ consumer. It owns:
from __future__ import annotations from __future__ import annotations
import json
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import text
from ..core.analysis.analyzer import RenderMetrics, render_markdown from ..core.analysis.analyzer import RenderMetrics, render_markdown
from ..core.analysis.checklist import checklist_for_prompt from ..core.analysis.checklist import checklist_for_prompt
from ..core.config import get_settings from ..core.config import get_settings
from ..core.credits import refund_credit from ..core.credits import refund_credit
from ..core.db.enums import DOC_TERMINAL, FailureClass from ..core.db.enums import DOC_TERMINAL, FailureClass
from ..core.db.repositories import DocumentRepository, JobRepository, ReportRepository
from ..core.llm.errors import ( from ..core.llm.errors import (
LLMConfigError, LLMConfigError,
LLMError, LLMError,
@ -75,17 +73,12 @@ class AnalyzeHandler:
async def _db_state( async def _db_state(
self, session: AsyncSession, document_id: uuid.UUID self, session: AsyncSession, document_id: uuid.UUID
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
row = await session.execute( return await DocumentRepository(session).get_status_and_filename_for_update(document_id)
text("SELECT status, filename FROM documents WHERE id = :d FOR UPDATE"),
{"d": document_id},
)
result = row.first()
if result is None:
return None, None
return (None if result[0] is None else str(result[0]), str(result[1]))
async def handle(self, payload: DocumentExtracted) -> None: async def handle(self, payload: DocumentExtracted) -> None:
async with self._session_factory() as session: async with self._session_factory() as session:
docs = DocumentRepository(session)
jobs = JobRepository(session)
status, filename = await self._db_state(session, payload.document_id) status, filename = await self._db_state(session, payload.document_id)
if status is None: if status is None:
log.warning("document_not_found", document_id=str(payload.document_id)) log.warning("document_not_found", document_id=str(payload.document_id))
@ -94,17 +87,8 @@ class AnalyzeHandler:
log.info("document_already_terminal", status=status) log.info("document_already_terminal", status=status)
return return
await session.execute( await docs.update_status(payload.document_id, status="analyzing", stage="llm")
text("UPDATE documents SET status = 'analyzing', stage = 'llm' WHERE id = :d"), await jobs.claim_start(payload.document_id, "analyze")
{"d": payload.document_id},
)
await session.execute(
text(
"UPDATE jobs SET status = 'running', attempts = attempts + 1 "
"WHERE document_id = :d AND queue = 'analyze'"
),
{"d": payload.document_id},
)
await session.commit() await session.commit()
text_bytes = await self._storage.get(payload.extracted_s3_key) text_bytes = await self._storage.get(payload.extracted_s3_key)
@ -137,46 +121,22 @@ class AnalyzeHandler:
repaired=result.repaired, repaired=result.repaired,
) )
markdown = render_markdown(result.findings, source_name, metrics) 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: async with self._session_factory() as session:
await session.execute( docs = DocumentRepository(session)
text( jobs = JobRepository(session)
"INSERT INTO reports " reports = ReportRepository(session)
"(document_id, content_json, markdown, model_used, " await reports.upsert_analyze_report(
" prompt_tokens, eval_tokens, latency_ms) " document_id=payload.document_id,
"VALUES (:d, CAST(:content AS jsonb), :md, :model, :pt, :et, :lat) " content_json={"findings": [f.model_dump() for f in result.findings]},
"ON CONFLICT (document_id) DO UPDATE SET " markdown=markdown,
" content_json = EXCLUDED.content_json, " model_used=result.model_used,
" markdown = EXCLUDED.markdown, " prompt_tokens=result.prompt_tokens,
" model_used = EXCLUDED.model_used, " eval_tokens=result.eval_tokens,
" prompt_tokens = EXCLUDED.prompt_tokens, " latency_ms=int(result.latency_sec * 1000),
" eval_tokens = EXCLUDED.eval_tokens, "
" latency_ms = EXCLUDED.latency_ms"
),
{
"d": payload.document_id,
"content": content_json,
"md": markdown,
"model": result.model_used,
"pt": result.prompt_tokens,
"et": result.eval_tokens,
"lat": int(result.latency_sec * 1000),
},
)
await session.execute(
text("UPDATE documents SET status = 'done', stage = 'done' WHERE id = :d"),
{"d": payload.document_id},
)
await session.execute(
text(
"UPDATE jobs SET status = 'done' WHERE document_id = :d AND queue = 'analyze'"
),
{"d": payload.document_id},
) )
await docs.update_status(payload.document_id, status="done", stage="done")
await jobs.mark_done(payload.document_id, "analyze")
await session.commit() await session.commit()
log.info( log.info(
@ -212,18 +172,12 @@ class AnalyzeHandler:
error=error, error=error,
) )
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( await JobRepository(session).mark_retrying(
text( payload.document_id,
"UPDATE jobs SET status = 'retrying', attempts = :a, " "analyze",
"last_failure_class = :fc, last_error = :err " attempt=attempt,
"WHERE document_id = :d AND queue = 'analyze'" failure_class=failure_class,
), error=error,
{
"a": attempt,
"fc": failure_class,
"err": error[:1000],
"d": payload.document_id,
},
) )
await session.commit() await session.commit()
@ -232,22 +186,12 @@ class AnalyzeHandler:
) -> None: ) -> None:
mq_failed.labels(queue="analyze", failure_class=failure_class).inc() mq_failed.labels(queue="analyze", failure_class=failure_class).inc()
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( docs = DocumentRepository(session)
text( jobs = JobRepository(session)
"UPDATE jobs SET status = 'dlq', dlq = TRUE, " await jobs.mark_dlq(
"last_failure_class = :fc, last_error = :err " payload.document_id, "analyze", failure_class=failure_class, error=error
"WHERE document_id = :d AND queue = 'analyze'"
),
{
"fc": failure_class,
"err": error[:1000],
"d": payload.document_id,
},
)
await session.execute(
text("UPDATE documents SET status = 'failed', stage = :stage WHERE id = :d"),
{"stage": failure_class, "d": payload.document_id},
) )
await docs.mark_failed(payload.document_id, stage=failure_class)
await refund_credit( await refund_credit(
session, session,
payload.document_id, payload.document_id,

View file

@ -18,11 +18,10 @@ import uuid
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import text
from ..core.config import get_settings from ..core.config import get_settings
from ..core.credits import refund_credit from ..core.credits import refund_credit
from ..core.db.enums import DOC_TERMINAL, FailureClass from ..core.db.enums import DOC_TERMINAL, FailureClass
from ..core.db.repositories import DocumentRepository, JobRepository
from ..core.logging import get_logger from ..core.logging import get_logger
from ..core.metrics import extract_duration, mq_failed, mq_published from ..core.metrics import extract_duration, mq_failed, mq_published
from ..core.mq.messages import ( from ..core.mq.messages import (
@ -69,15 +68,12 @@ class ExtractHandler:
return self._publisher return self._publisher
async def _db_status(self, session: AsyncSession, document_id: uuid.UUID) -> str | None: async def _db_status(self, session: AsyncSession, document_id: uuid.UUID) -> str | None:
row = await session.execute( return await DocumentRepository(session).get_status_for_update(document_id)
text("SELECT status FROM documents WHERE id = :d FOR UPDATE"),
{"d": document_id},
)
result = row.first()
return str(result[0]) if result else None
async def handle(self, payload: DocumentUploaded) -> None: async def handle(self, payload: DocumentUploaded) -> None:
async with self._session_factory() as session: async with self._session_factory() as session:
docs = DocumentRepository(session)
jobs = JobRepository(session)
current = await self._db_status(session, payload.document_id) current = await self._db_status(session, payload.document_id)
if current is None: if current is None:
log.warning("document_not_found", document_id=str(payload.document_id)) log.warning("document_not_found", document_id=str(payload.document_id))
@ -86,20 +82,8 @@ class ExtractHandler:
log.info("document_already_terminal", status=current) log.info("document_already_terminal", status=current)
return return
await session.execute( await docs.update_status(payload.document_id, status="extracting", stage="downloading")
text( await jobs.claim_start(payload.document_id, "extract")
"UPDATE documents SET status = 'extracting', stage = 'downloading' "
"WHERE id = :d"
),
{"d": payload.document_id},
)
await session.execute(
text(
"UPDATE jobs SET status = 'running', attempts = attempts + 1 "
"WHERE document_id = :d AND queue = 'extract'"
),
{"d": payload.document_id},
)
await session.commit() await session.commit()
with extract_duration.time(): with extract_duration.time():
@ -146,24 +130,15 @@ class ExtractHandler:
mq_published.labels(queue=queue_metric).inc() mq_published.labels(queue=queue_metric).inc()
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( docs = DocumentRepository(session)
text( jobs = JobRepository(session)
"UPDATE documents SET status = :status, stage = :stage, " await docs.update_status(
"extracted_s3_key = :key WHERE id = :d" payload.document_id,
), status=doc_status,
{ stage=doc_stage,
"status": doc_status, extracted_s3_key=ext_key,
"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},
) )
await jobs.mark_done(payload.document_id, "extract")
await session.commit() await session.commit()
log.info( log.info(
@ -194,13 +169,8 @@ class ExtractHandler:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
async def _update_stage(self, document_id: uuid.UUID, stage: str) -> None: async def _update_stage(self, document_id: uuid.UUID, stage: str) -> None:
from sqlalchemy import text
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( await DocumentRepository(session).update_stage(document_id, stage=stage)
text("UPDATE documents SET stage = :stage WHERE id = :d"),
{"stage": stage, "d": document_id},
)
await session.commit() await session.commit()
def classify(self, exc: BaseException) -> FailureClass: def classify(self, exc: BaseException) -> FailureClass:
@ -216,22 +186,14 @@ class ExtractHandler:
async def on_failure( async def on_failure(
self, payload: DocumentUploaded, failure_class: FailureClass, attempt: int, error: str self, payload: DocumentUploaded, failure_class: FailureClass, attempt: int, error: str
) -> None: ) -> None:
from sqlalchemy import text
mq_failed.labels(queue="extract", failure_class=failure_class).inc() mq_failed.labels(queue="extract", failure_class=failure_class).inc()
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( await JobRepository(session).mark_retrying(
text( payload.document_id,
"UPDATE jobs SET status = 'retrying', attempts = :a, " "extract",
"last_failure_class = :fc, last_error = :err " attempt=attempt,
"WHERE document_id = :d AND queue = 'extract'" failure_class=failure_class,
), error=error,
{
"a": attempt,
"fc": failure_class,
"err": error[:1000],
"d": payload.document_id,
},
) )
await session.commit() await session.commit()
@ -240,22 +202,12 @@ class ExtractHandler:
) -> None: ) -> None:
mq_failed.labels(queue="extract", failure_class=failure_class).inc() mq_failed.labels(queue="extract", failure_class=failure_class).inc()
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( docs = DocumentRepository(session)
text( jobs = JobRepository(session)
"UPDATE jobs SET status = 'dlq', dlq = TRUE, " await jobs.mark_dlq(
"last_failure_class = :fc, last_error = :err " payload.document_id, "extract", failure_class=failure_class, error=error
"WHERE document_id = :d AND queue = 'extract'"
),
{
"fc": failure_class,
"err": error[:1000],
"d": payload.document_id,
},
)
await session.execute(
text("UPDATE documents SET status = 'failed', stage = :stage WHERE id = :d"),
{"stage": failure_class, "d": payload.document_id},
) )
await docs.mark_failed(payload.document_id, stage=failure_class)
await refund_credit( await refund_credit(
session, session,
payload.document_id, payload.document_id,

View file

@ -14,17 +14,20 @@ in-process without spinning up a real RabbitMQ consumer. It owns:
from __future__ import annotations from __future__ import annotations
import json
import time import time
import uuid import uuid
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import text
from ..core.config import get_settings from ..core.config import get_settings
from ..core.credits import refund_credit from ..core.credits import refund_credit
from ..core.db.enums import DOC_TERMINAL, FailureClass from ..core.db.enums import DOC_TERMINAL, FailureClass
from ..core.db.repositories import (
DocumentRepository,
JobRepository,
PrescreenResultRepository,
ReportRepository,
)
from ..core.logging import ( from ..core.logging import (
Timer, Timer,
get_logger, get_logger,
@ -104,14 +107,10 @@ class PrescreenHandler:
return self._publisher return self._publisher
async def _db_status(self, session: AsyncSession, document_id: uuid.UUID) -> str | None: async def _db_status(self, session: AsyncSession, document_id: uuid.UUID) -> str | None:
row = await session.execute( return await DocumentRepository(session).get_status_for_update(document_id)
text("SELECT status FROM documents WHERE id = :d FOR UPDATE"),
{"d": document_id},
)
result = row.first()
return str(result[0]) if result else None
async def handle(self, payload: PrescreenRequested) -> None: async def handle(self, payload: PrescreenRequested) -> None:
"""Orchestrate the prescreen pipeline in eight stages."""
started_at = time.perf_counter() started_at = time.perf_counter()
log_message_processing( log_message_processing(
log, log,
@ -125,7 +124,103 @@ class PrescreenHandler:
attempt=payload.attempt, 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)): with Timer(log, "prescreen_db_validation", document_id=str(payload.document_id)):
async with self._session_factory() as session: async with self._session_factory() as session:
current = await self._db_status(session, payload.document_id) current = await self._db_status(session, payload.document_id)
@ -136,39 +231,31 @@ class PrescreenHandler:
user_id=str(payload.user_id), user_id=str(payload.user_id),
text_s3_key=payload.text_s3_key, text_s3_key=payload.text_s3_key,
) )
return return None
if current in DOC_TERMINAL: if current in DOC_TERMINAL:
log.info( log.info(
"document_already_terminal", "document_already_terminal",
document_id=str(payload.document_id), document_id=str(payload.document_id),
status=current, 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)): with Timer(log, "prescreen_status_update", document_id=str(payload.document_id)):
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( docs = DocumentRepository(session)
text( jobs = JobRepository(session)
"UPDATE documents SET status = 'prescreening', stage = 'extracting_meta' " await docs.update_status(
"WHERE id = :d" payload.document_id,
), status="prescreening",
{"d": payload.document_id}, stage="extracting_meta",
) )
await session.execute( await jobs.upsert_running(
text( payload.document_id,
"INSERT INTO jobs (document_id, correlation_id, queue, status) " payload.correlation_id,
"VALUES (:d, :cid, 'prescreen', 'running') " queue="prescreen",
"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 session.commit() await session.commit()
log_db_operation( log_db_operation(
@ -180,7 +267,8 @@ class PrescreenHandler:
stage="extracting_meta", 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 text_bytes: bytes
contract_text: str contract_text: str
with Timer( with Timer(
@ -216,11 +304,15 @@ class PrescreenHandler:
text_s3_key=payload.text_s3_key, text_s3_key=payload.text_s3_key,
char_count=len(contract_text), 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 meta: PrescreenContractMeta
extraction: ExtractionResult 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: try:
extraction = await self._extractor.extract(contract_text) extraction = await self._extractor.extract(contract_text)
meta = extraction.meta meta = extraction.meta
@ -229,7 +321,7 @@ class PrescreenHandler:
"extraction_confidence", "extraction_confidence",
meta.confidence_score, meta.confidence_score,
"ratio", "ratio",
document_id=str(payload.document_id), document_id=str(document_id),
extractor_version=extraction.extractor_version, extractor_version=extraction.extractor_version,
llm_fallback_error=extraction.llm_fallback_error, llm_fallback_error=extraction.llm_fallback_error,
extracted_fields=sum( extracted_fields=sum(
@ -239,113 +331,64 @@ class PrescreenHandler:
) )
except Exception as exc: except Exception as exc:
log_stage_failure( 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 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 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) decision = self._router.decide(meta)
log_stage_progress( log_stage_progress(
log, log,
"prescreen_routing_decision", "prescreen_routing_decision",
"decision_made", "decision_made",
document_id=str(payload.document_id), document_id=str(document_id),
decision=decision, decision=decision,
confidence=meta.confidence_score, confidence=meta.confidence_score,
) )
processing_ms = int((time.perf_counter() - started_at) * 1000) return decision
prescreened_at = datetime.now(UTC) async def _persist(
prescreened_at_str = prescreened_at.isoformat() self,
completed = PrescreenCompleted( payload: PrescreenRequested,
correlation_id=payload.correlation_id, completed: PrescreenCompleted,
document_id=payload.document_id, extraction: ExtractionResult,
user_id=payload.user_id, processing_ms: int,
text_s3_key=payload.text_s3_key, prescreened_at: datetime,
filename=payload.filename, ) -> uuid.UUID | None:
prescreened_at=prescreened_at_str, """Persist the prescreen result row and return its id."""
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
prescreen_result_id: uuid.UUID | None = None prescreen_result_id: uuid.UUID | None = None
with Timer(log, "prescreen_result_persistence", document_id=str(payload.document_id)): with Timer(log, "prescreen_result_persistence", document_id=str(payload.document_id)):
try: try:
async with self._session_factory() as session: async with self._session_factory() as session:
result = await session.execute( prescreen_result_id = await PrescreenResultRepository(
text( session
"INSERT INTO prescreen_results " ).create_from_completed(
"(document_id, correlation_id, contract_type, party_a, party_b, " document_id=payload.document_id,
" total_amount, currency, start_date, end_date, has_penalty_clause, " correlation_id=payload.correlation_id,
" has_termination_clause, has_arbitration, confidence_score, " contract_type=completed.contract_type,
" routing_decision, prescreened_at, processing_ms, auto_summary, " party_a=completed.party_a,
" auto_findings, extractor_version) " party_b=completed.party_b,
"VALUES (:d, :cid, :ct, :pa, :pb, :ta, :cur, :sd, :ed, :hpc, :htc, :ha, " total_amount=completed.total_amount,
" :cs, :rd, :psa, :ms, :asum, :af, :ev) " currency=completed.currency,
"RETURNING id" start_date=_as_date(completed.start_date),
), end_date=_as_date(completed.end_date),
{ has_penalty_clause=completed.has_penalty_clause,
"d": payload.document_id, has_termination_clause=completed.has_termination_clause,
"cid": payload.correlation_id, has_arbitration=completed.has_arbitration,
"ct": completed.contract_type, confidence_score=completed.confidence_score,
"pa": completed.party_a, routing_decision=completed.routing_decision,
"pb": completed.party_b, prescreened_at=prescreened_at,
"ta": completed.total_amount, processing_ms=processing_ms,
"cur": completed.currency, auto_summary=completed.auto_summary,
"sd": _as_date(completed.start_date), auto_findings=completed.auto_findings,
"ed": _as_date(completed.end_date), extractor_version=extraction.extractor_version,
"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 = result.scalar()
await session.commit() await session.commit()
log_db_operation( log_db_operation(
log, log,
@ -360,13 +403,17 @@ class PrescreenHandler:
) )
raise raise
prescreen_duration.labels(decision=decision).observe(processing_ms / 1000.0) return prescreen_result_id
prescreen_runs.labels(
decision=decision, contract_type=meta.contract_type or "unknown"
).inc()
prescreen_confidence.observe(meta.confidence_score)
# 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() publisher = await self._publisher_instance()
next_stage: str next_stage: str
@ -419,7 +466,7 @@ class PrescreenHandler:
session_factory=self._session_factory, session_factory=self._session_factory,
payload=payload, payload=payload,
completed=completed, completed=completed,
prescreen_result_id=prescreen_result_id, # type: ignore[arg-type] prescreen_result_id=prescreen_result_id,
) )
doc_status = "done" doc_status = "done"
next_stage = "auto_approved" next_stage = "auto_approved"
@ -430,19 +477,22 @@ class PrescreenHandler:
document_id=str(payload.document_id), 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)): with Timer(log, "prescreen_final_status_update", document_id=str(payload.document_id)):
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( docs = DocumentRepository(session)
text("UPDATE documents SET status = :status, stage = :stage WHERE id = :d"), jobs = JobRepository(session)
{"status": doc_status, "stage": next_stage, "d": payload.document_id}, await docs.update_status(
) payload.document_id,
await session.execute( status=doc_status,
text( stage=next_stage,
"UPDATE jobs SET status = 'done' WHERE document_id = :d AND queue = 'prescreen'"
),
{"d": payload.document_id},
) )
await jobs.mark_done(payload.document_id, queue="prescreen")
await session.commit() await session.commit()
log_db_operation( log_db_operation(
log, log,
@ -453,28 +503,13 @@ class PrescreenHandler:
final_stage=next_stage, 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( async def _write_auto_approved_report(
self, self,
*, *,
session_factory: async_sessionmaker[AsyncSession], session_factory: async_sessionmaker[AsyncSession],
payload: PrescreenRequested, payload: PrescreenRequested,
completed: PrescreenCompleted, completed: PrescreenCompleted,
prescreen_result_id: uuid.UUID, prescreen_result_id: uuid.UUID | None,
) -> None: ) -> None:
content: dict[str, object] = { content: dict[str, object] = {
"findings": [ "findings": [
@ -496,30 +531,12 @@ class PrescreenHandler:
"*Это автоматический отчёт без полного анализа ИИ.*" "*Это автоматический отчёт без полного анализа ИИ.*"
) )
async with session_factory() as session: async with session_factory() as session:
await session.execute( await ReportRepository(session).upsert_auto_approved(
text( document_id=payload.document_id,
"INSERT INTO reports " content_json=content,
"(document_id, content_json, markdown, model_used, prompt_tokens, " markdown=markdown,
" eval_tokens, latency_ms, prescreen_result_id, prescreen_meta) " prescreen_result_id=prescreen_result_id or uuid.UUID(int=0),
"VALUES (:d, CAST(:content AS jsonb), :md, :model, 0, 0, 0, :prid, :pm) " prescreen_meta=completed.model_dump(),
"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 session.commit() await session.commit()
@ -561,18 +578,12 @@ class PrescreenHandler:
error=error, error=error,
) )
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( await JobRepository(session).mark_retrying(
text( payload.document_id,
"UPDATE jobs SET status = 'retrying', attempts = :a, " queue="prescreen",
"last_failure_class = :fc, last_error = :err " attempt=attempt,
"WHERE document_id = :d AND queue = 'prescreen'" failure_class=failure_class,
), error=error,
{
"a": attempt,
"fc": failure_class,
"err": error[:1000],
"d": payload.document_id,
},
) )
await session.commit() await session.commit()
@ -581,22 +592,15 @@ class PrescreenHandler:
) -> None: ) -> None:
mq_failed.labels(queue="prescreen", failure_class=failure_class).inc() mq_failed.labels(queue="prescreen", failure_class=failure_class).inc()
async with self._session_factory() as session: async with self._session_factory() as session:
await session.execute( jobs = JobRepository(session)
text( docs = DocumentRepository(session)
"UPDATE jobs SET status = 'dlq', dlq = TRUE, " await jobs.mark_dlq(
"last_failure_class = :fc, last_error = :err " payload.document_id,
"WHERE document_id = :d AND queue = 'prescreen'" queue="prescreen",
), failure_class=failure_class,
{ error=error,
"fc": failure_class,
"err": error[:1000],
"d": payload.document_id,
},
)
await session.execute(
text("UPDATE documents SET status = 'failed', stage = :stage WHERE id = :d"),
{"stage": failure_class, "d": payload.document_id},
) )
await docs.mark_failed(payload.document_id, stage=failure_class)
await refund_credit( await refund_credit(
session, session,
payload.document_id, payload.document_id,

View file

@ -6,15 +6,12 @@ When disabled, any auto_approve decision is remapped to manual_review.
from __future__ import annotations from __future__ import annotations
from typing import Literal from ..core.db.enums import RoutingDecision
from ..core.logging import get_logger from ..core.logging import get_logger
from .extractor import PrescreenContractMeta from .extractor import PrescreenContractMeta
log = get_logger(__name__) log = get_logger(__name__)
type RoutingDecision = Literal["auto_approve", "manual_review", "deep_analysis"]
class PrescreenRouter: class PrescreenRouter:
"""Decide where a prescreened document goes next.""" """Decide where a prescreened document goes next."""

View file

@ -1,8 +1,8 @@
"""Shared pytest fixtures. """Shared pytest fixtures.
Unit tests stay fast and dependency-free (no DB/MQ/S3). Integration tests use Unit tests stay fast and dependency-free (no DB/MQ/S3). Integration tests use
testcontainers and are marked `@pytest.mark.integration` (deselected by the the running Docker Compose infrastructure and are marked `@pytest.mark.integration`
default `pytest -q` run). (deselected by the default `pytest -q` run).
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -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 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 from __future__ import annotations
@ -10,50 +11,21 @@ import asyncio
import uuid import uuid
import pytest import pytest
import pytest_asyncio
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession
from testcontainers.community.postgres import PostgresContainer
from contract_check.core.credits import refund_credit, reserve_credit from contract_check.core.credits import refund_credit, reserve_credit
pytestmark = pytest.mark.integration 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: async def insert_user(session: AsyncSession, credits: int = 5) -> uuid.UUID:
result = await session.execute( 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}, {"c": credits},
) )
user_id = result.scalar_one() 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: async def insert_doc(session: AsyncSession, user_id: uuid.UUID) -> uuid.UUID:
result = await session.execute( 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}, {"u": user_id},
) )
doc_id = result.scalar_one() 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()) return int(result.scalar_one())
async def test_reserve_credit_decrements_once(pg_session: AsyncSession) -> None: async def test_reserve_credit_decrements_once(db_session: AsyncSession) -> None:
u = await insert_user(pg_session, credits=2) u = await insert_user(db_session, credits=2)
ok = await reserve_credit(pg_session, u) ok = await reserve_credit(db_session, u)
assert ok is True assert ok is True
await pg_session.commit() await db_session.commit()
assert await credit_balance(pg_session, u) == 1 assert await credit_balance(db_session, u) == 1
async def test_reserve_credit_rejects_when_zero(pg_session: AsyncSession) -> None: async def test_reserve_credit_rejects_when_zero(db_session: AsyncSession) -> None:
u = await insert_user(pg_session, credits=0) u = await insert_user(db_session, credits=0)
ok = await reserve_credit(pg_session, u) ok = await reserve_credit(db_session, u)
assert ok is False assert ok is False
async def test_reserve_credit_never_goes_negative(pg_session: AsyncSession) -> None: async def test_reserve_credit_never_goes_negative(db_session: AsyncSession) -> None:
u = await insert_user(pg_session, credits=1) u = await insert_user(db_session, credits=1)
results = await asyncio.gather(
reserve_credit(pg_session, u), async def _attempt() -> bool:
reserve_credit(pg_session, u), # Each concurrent attempt must use its own DB session/connection.
reserve_credit(pg_session, u), 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. # The atomic UPDATE WHERE credits_left>0 serializes concurrent attempts.
assert sum(1 for r in results if r) == 1 assert sum(1 for r in results if r) == 1
await pg_session.commit() assert await credit_balance(db_session, u) == 0
assert await credit_balance(pg_session, u) == 0
async def test_refund_credit_idempotent(pg_session: AsyncSession) -> None: async def test_refund_credit_idempotent(db_session: AsyncSession) -> None:
u = await insert_user(pg_session, credits=0) u = await insert_user(db_session, credits=0)
d = await insert_doc(pg_session, u) 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 assert ok1 is True
await pg_session.commit() await db_session.commit()
assert await credit_balance(pg_session, u) == 1 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 assert ok2 is False # already refunded
await pg_session.commit() await db_session.commit()
assert await credit_balance(pg_session, u) == 1 assert await credit_balance(db_session, u) == 1
async def test_refund_credit_respects_infra_only(pg_session: AsyncSession) -> None: async def test_refund_credit_respects_infra_only(db_session: AsyncSession) -> None:
u = await insert_user(pg_session, credits=0) u = await insert_user(db_session, credits=0)
d = await insert_doc(pg_session, u) 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 assert ok is False # user pays for garbage
await pg_session.commit() await db_session.commit()
assert await credit_balance(pg_session, u) == 0 assert await credit_balance(db_session, u) == 0
# LLM failure is refunded under infra_only. # LLM failure is refunded under infra_only.
d2 = await insert_doc(pg_session, u) d2 = await insert_doc(db_session, u)
ok2 = await refund_credit(pg_session, d2, "llm_quota", "infra_only") ok2 = await refund_credit(db_session, d2, "llm_quota", "infra_only")
assert ok2 is True assert ok2 is True
await pg_session.commit() await db_session.commit()
assert await credit_balance(pg_session, u) == 1 assert await credit_balance(db_session, u) == 1

41
tests/unit/conftest.py Normal file
View file

@ -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()

View file

@ -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"

View file

@ -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)

View file

@ -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

View file

@ -99,7 +99,7 @@ def test_build_registration_options_matches_frontend_schema() -> None:
assert options["excludeCredentials"] == [{"id": existing, "type": "public-key"}] assert options["excludeCredentials"] == [{"id": existing, "type": "public-key"}]
# Roundtrips through the API response model without coercion errors. # 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 assert PasskeyRegistrationOptions(**options).challenge == challenge
@ -113,7 +113,7 @@ def test_build_authentication_options_matches_frontend_schema() -> None:
assert options["allowCredentials"] == [] assert options["allowCredentials"] == []
assert options["userVerification"] == "required" assert options["userVerification"] == "required"
from contract_check.api.routes.auth import PasskeyAuthenticationOptions from contract_check.api.schemas import PasskeyAuthenticationOptions
assert PasskeyAuthenticationOptions(**options).rpId == "localhost" assert PasskeyAuthenticationOptions(**options).rpId == "localhost"

View file

@ -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

383
uv.lock generated
View file

@ -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" }, { 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]] [[package]]
name = "asyncpg" name = "asyncpg"
version = "0.31.0" version = "0.31.0"
@ -769,7 +705,6 @@ dev = [
{ name = "jinja2" }, { name = "jinja2" },
{ name = "mammoth" }, { name = "mammoth" },
{ name = "minio" }, { name = "minio" },
{ name = "mypy" },
{ name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-exporter-otlp" },
{ name = "opentelemetry-instrumentation-asgi" }, { name = "opentelemetry-instrumentation-asgi" },
{ name = "opentelemetry-instrumentation-fastapi" }, { name = "opentelemetry-instrumentation-fastapi" },
@ -783,6 +718,7 @@ dev = [
{ name = "pytesseract" }, { name = "pytesseract" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "python-docx" }, { name = "python-docx" },
{ name = "python-magic" }, { name = "python-magic" },
{ name = "python-multipart" }, { name = "python-multipart" },
@ -792,7 +728,6 @@ dev = [
{ name = "sentry-sdk" }, { name = "sentry-sdk" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "striprtf" }, { name = "striprtf" },
{ name = "testcontainers", extra = ["minio", "rabbitmq"] },
{ name = "ty" }, { name = "ty" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
{ name = "webauthn" }, { name = "webauthn" },
@ -926,7 +861,6 @@ dev = [
{ name = "jinja2", specifier = ">=3.1" }, { name = "jinja2", specifier = ">=3.1" },
{ name = "mammoth", specifier = ">=1.8" }, { name = "mammoth", specifier = ">=1.8" },
{ name = "minio", specifier = ">=7.2" }, { name = "minio", specifier = ">=7.2" },
{ name = "mypy", specifier = ">=1.10" },
{ name = "opentelemetry-exporter-otlp", specifier = ">=1.24" }, { name = "opentelemetry-exporter-otlp", specifier = ">=1.24" },
{ name = "opentelemetry-instrumentation-asgi", specifier = ">=0.45b0" }, { name = "opentelemetry-instrumentation-asgi", specifier = ">=0.45b0" },
{ name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.45b0" }, { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.45b0" },
@ -940,6 +874,7 @@ dev = [
{ name = "pytesseract", specifier = ">=0.3.10" }, { name = "pytesseract", specifier = ">=0.3.10" },
{ name = "pytest", specifier = ">=8" }, { name = "pytest", specifier = ">=8" },
{ name = "pytest-asyncio", specifier = ">=0.23" }, { name = "pytest-asyncio", specifier = ">=0.23" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "python-docx", specifier = ">=1.1" }, { name = "python-docx", specifier = ">=1.1" },
{ name = "python-magic", specifier = ">=0.4.27" }, { name = "python-magic", specifier = ">=0.4.27" },
{ name = "python-multipart", specifier = ">=0.0.9" }, { name = "python-multipart", specifier = ">=0.0.9" },
@ -949,7 +884,6 @@ dev = [
{ name = "sentry-sdk", specifier = ">=2" }, { name = "sentry-sdk", specifier = ">=2" },
{ name = "sqlalchemy", specifier = ">=2.0" }, { name = "sqlalchemy", specifier = ">=2.0" },
{ name = "striprtf", specifier = ">=0.0.26" }, { name = "striprtf", specifier = ">=0.0.26" },
{ name = "testcontainers", extras = ["rabbitmq", "postgres", "minio"], specifier = ">=4" },
{ name = "ty", specifier = ">=0.0.72" }, { name = "ty", specifier = ">=0.0.72" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" },
{ name = "webauthn", specifier = ">=2.5" }, { name = "webauthn", specifier = ">=2.5" },
@ -1008,6 +942,90 @@ prototype = [
] ]
s3 = [{ name = "minio", specifier = ">=7.2" }] 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]] [[package]]
name = "cryptography" name = "cryptography"
version = "50.0.0" 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" }, { 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]] [[package]]
name = "email-validator" name = "email-validator"
version = "2.3.0" 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" }, { 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]] [[package]]
name = "lxml" name = "lxml"
version = "6.1.1" 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" }, { 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]] [[package]]
name = "nodeenv" name = "nodeenv"
version = "1.10.0" 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" }, { 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]] [[package]]
name = "pillow" name = "pillow"
version = "12.3.0" 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" }, { 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]] [[package]]
name = "python-discovery" name = "python-discovery"
version = "1.5.2" 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" }, { 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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" 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" }, { 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]] [[package]]
name = "ty" name = "ty"
version = "0.0.72" version = "0.0.72"