diff --git a/.gitea/workflows/build-backend.yml b/.gitea/workflows/build-backend.yml new file mode 100644 index 0000000..7851896 --- /dev/null +++ b/.gitea/workflows/build-backend.yml @@ -0,0 +1,105 @@ +name: build-backend + +# Builds + pushes the backend image to justin/provenance-backend's package area +# on Gitea on every merge to main. Servers pull from git.jpaul.io. +# +# Push goes to the LAN registry endpoint 192.168.0.2:1234 (plain HTTP) to bypass +# Cloudflare's request-body limit; pulls use the public git.jpaul.io FQDN. Same +# Gitea registry either way. Mirrors the drawbar setup. +# +# Tag scheme: test-main | test-sha- | | latest (v* tags) + +on: + workflow_dispatch: + push: + branches: [main] + tags: ['v*'] + paths: + - 'backend/**' + - '.gitea/workflows/build-backend.yml' + +concurrency: + group: build-backend-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from pyproject.toml + id: ver + run: | + v=$(grep -oP '^version = "\K[^"]+' backend/pyproject.toml | head -1) + if [ -z "$v" ]; then echo "could not parse version from backend/pyproject.toml"; exit 1; fi + echo "semver=$v" >> "$GITHUB_OUTPUT" + echo "backend semver: $v" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + # LAN registry serves plain HTTP on :1234 (git.jpaul.io is the only TLS + # endpoint, via Cloudflare). Treat the LAN endpoint as insecure so + # buildkit doesn't try to upgrade the push to HTTPS. + config-inline: | + [registry."192.168.0.2:1234"] + http = true + insecure = true + + - name: Configure registry credentials for buildx + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_USER: ${{ github.actor }} + run: | + mkdir -p ~/.docker + AUTH=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 -w0) + cat > ~/.docker/config.json < provenance: HTTP $code" + case "$code" in + 201) echo "OK — newly linked" ;; + 400|409) echo "OK — already linked" ;; + *) cat /tmp/link.out; exit 1 ;; + esac diff --git a/.gitea/workflows/build-frontend.yml b/.gitea/workflows/build-frontend.yml new file mode 100644 index 0000000..99289d8 --- /dev/null +++ b/.gitea/workflows/build-frontend.yml @@ -0,0 +1,102 @@ +name: build-frontend + +# Builds + pushes the Next.js image to justin/provenance-frontend's package area +# on Gitea on every merge to main. Servers pull from git.jpaul.io. +# +# Push -> LAN registry 192.168.0.2:1234 (plain HTTP); pull -> git.jpaul.io. +# Mirrors the drawbar setup; see build-backend.yml for the rationale. +# +# Tag scheme: test-main | test-sha- | | latest (v* tags) + +on: + workflow_dispatch: + push: + branches: [main] + tags: ['v*'] + paths: + - 'frontend/**' + - '.gitea/workflows/build-frontend.yml' + +concurrency: + group: build-frontend-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from package.json + id: ver + run: | + v=$(grep -oP '"version"\s*:\s*"\K[^"]+' frontend/package.json | head -1) + if [ -z "$v" ]; then echo "could not parse version from frontend/package.json"; exit 1; fi + echo "semver=$v" >> "$GITHUB_OUTPUT" + echo "frontend version: $v" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + # See build-backend.yml for why these flags are needed. + config-inline: | + [registry."192.168.0.2:1234"] + http = true + insecure = true + + - name: Configure registry credentials for buildx + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_USER: ${{ github.actor }} + run: | + mkdir -p ~/.docker + AUTH=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 -w0) + cat > ~/.docker/config.json < provenance: HTTP $code" + case "$code" in + 201) echo "OK — newly linked" ;; + 400|409) echo "OK — already linked" ;; + *) cat /tmp/link.out; exit 1 ;; + esac diff --git a/.gitignore b/.gitignore index d63258e..f2f7c85 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,11 @@ target/ dist/ build/ +# Tooling caches +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + # Logs *.log npm-debug.log* diff --git a/CLAUDE.md b/CLAUDE.md index 5dbc2ef..5484507 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ These are product invariants, not preferences. Do not violate them, and flag any - **Object storage:** S3-compatible (MinIO for self-host). - **Edge:** Caddy reverse proxy; optional Cloudflare Tunnel (preferred ingress, never required). - **Email:** operator-configured SMTP. -- **CI/CD:** Gitea Actions on `git.jpaul.io` build container images to the Gitea registry; servers pull to deploy. +- **CI/CD:** Gitea Actions build per-component images. **Push** to the LAN registry `192.168.0.2:1234` (plain HTTP, bypasses Cloudflare's body limit); **pull** via the public `git.jpaul.io` FQDN. Servers pull to deploy — no host build. Mirrors the drawbar setup; see [[gitea-lan-push-fqdn-pull]]. Pick libraries consistent with this stack. If you introduce a significant dependency or a new service, note it in ARCHITECTURE.md in the same change. @@ -38,13 +38,17 @@ Pick libraries consistent with this stack. If you introduce a significant depend ``` / # docs and project meta (this file, README, LICENSE, COC, CONTRIBUTING) /docs # PRD.md, ARCHITECTURE.md +/backend # FastAPI service (uv-managed). app/{api/v1, services (+ privacy engine), repositories, models, schemas, integrations (auth/mailer), core}; migrations/ = Alembic +/deploy # docker-compose.yml, Caddyfile, .env.example — the self-host stack +/.gitea/workflows # Gitea Actions CI (build images → Gitea registry) +/frontend # Next.js (App Router, TS, Tailwind, shadcn-style UI). app/ pages, lib/api generated OpenAPI client, components/ui ``` -Code does not exist yet — Phase 0 has not landed. When you scaffold it, propose a layout (e.g. `/backend`, `/frontend`, `/deploy` for compose/Caddy) and record it here and in ARCHITECTURE.md. Keep this section current as the tree grows. +Phase 0 is landing **deploy-first**: the compose stack (Postgres + MinIO + Caddy + a minimal FastAPI backend exposing `/health` and `/health/ready`) and CI come before the real data model and the frontend. Backend dependencies are managed with **uv**; migrations use **Alembic**. The core data model (ARCHITECTURE §5), **local auth** (Argon2 passwords, backend-issued sessions, email verify/reset behind the `AuthProvider` interface; API auth via Bearer header or HttpOnly cookie), and the **Next.js frontend scaffold** (Tailwind + shadcn-style UI, generated OpenAPI client, auth + tree/person views) have all landed — **Phase 0 is complete and running on the live deployment.** Phase 1 (core tree features — media, soft-delete recovery, richer CRUD) is next; OIDC/social auth is Phase 5. Keep this section current as the tree grows. ## Where to start -The roadmap is phased in PRD §8. Build in dependency order. **Phase 0 — Foundation** is the current target: +The roadmap is phased in PRD §8. Build in dependency order. **Phase 0 — Foundation is complete** and running on the live deployment; **Phase 1 (core tree features) is the current target.** For reference, Phase 0 covered: 1. Backend skeleton (FastAPI, async, layered) + Postgres + migrations 2. Core data model from ARCHITECTURE §5 — start with User, Tree, TreeMembership, Person, Name, Relationship, Event, Place, Source, Citation, AuditEntry, soft-delete support diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..eaed3c5 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +.venv/ +venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.env +.env.* +!.env.example +*.md diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0340f0f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +# uv-managed Python image keeps the toolchain reproducible. Pinned to 3.13 for +# broad wheel availability (asyncpg etc.); bump when 3.14 wheels are ubiquitous. +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +WORKDIR /app + +# Dependencies first for layer caching. uv.lock is optional on first build; +# `uv sync` resolves and writes it if absent. +COPY pyproject.toml uv.lock* ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev + +# Application source + migrations (project is package=false, no install step). +COPY app ./app +COPY alembic.ini ./alembic.ini +COPY migrations ./migrations + +EXPOSE 8000 + +CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..e17373c --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,41 @@ +# Alembic config. The database URL is injected from DATABASE_URL in +# migrations/env.py (twelve-factor) — intentionally not set here. + +[alembic] +script_location = migrations +prepend_sys_path = . +path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..637809a --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,48 @@ +"""Shared API dependencies: DB session, the authenticated user, and the mailer.""" + +from typing import Annotated + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.db import get_session +from app.integrations.mailer.base import Mailer +from app.integrations.mailer.console import ConsoleMailer +from app.integrations.mailer.smtp import SMTPMailer +from app.models.user import User +from app.services import auth_service + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +def extract_session_token(request: Request) -> str | None: + """Bearer header (API clients) takes precedence over the session cookie + (browser).""" + authorization = request.headers.get("authorization") + if authorization and authorization.lower().startswith("bearer "): + return authorization[7:].strip() + return request.cookies.get(get_settings().cookie_name) + + +async def get_current_user(request: Request, session: SessionDep) -> User: + raw_token = extract_session_token(request) + if raw_token is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication required") + user = await auth_service.resolve_session_user(session, raw_token=raw_token) + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or expired session") + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] + + +def get_mailer() -> Mailer: + settings = get_settings() + if settings.mailer == "smtp" and settings.smtp_host: + return SMTPMailer(settings) + return ConsoleMailer() + + +MailerDep = Annotated[Mailer, Depends(get_mailer)] diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..3fc1b62 --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,41 @@ +"""Liveness and readiness endpoints. + +- ``/health`` — liveness: the process is up. No dependencies touched. +- ``/health/ready`` — readiness: dependencies (Postgres) are reachable. + +Orchestrators and Caddy probe these; they are intentionally outside the +versioned ``/api`` surface. +""" + +from fastapi import APIRouter, Response, status +from sqlalchemy import text + +from app.core.config import get_settings +from app.core.db import get_engine + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +async def health() -> dict: + settings = get_settings() + return { + "status": "ok", + "service": settings.app_name, + "version": settings.version, + "env": settings.app_env, + } + + +@router.get("/health/ready") +async def ready(response: Response) -> dict: + checks: dict[str, str] = {} + try: + async with get_engine().connect() as conn: + await conn.execute(text("SELECT 1")) + checks["database"] = "ok" + return {"status": "ready", "checks": checks} + except Exception as exc: # noqa: BLE001 — surface any failure as "not ready" + checks["database"] = "error" + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return {"status": "not ready", "checks": checks, "detail": str(exc)} diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..ee7faa3 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,11 @@ +"""Versioned API surface. Mounts under /api/v1.""" + +from fastapi import APIRouter + +from app.api.v1 import auth, persons, trees, users + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(auth.router) +api_router.include_router(users.router) +api_router.include_router(trees.router) +api_router.include_router(persons.router) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..fc20c93 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, HTTPException, Request, Response, status + +from app.api.deps import MailerDep, SessionDep, extract_session_token +from app.core.config import get_settings +from app.schemas.auth import ( + LoginRequest, + PasswordResetConfirm, + PasswordResetRequest, + RegisterRequest, + SessionRead, + TokenRequest, +) +from app.schemas.user import UserRead +from app.services import auth_service + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _set_session_cookie(response: Response, token: str) -> None: + settings = get_settings() + response.set_cookie( + settings.cookie_name, + token, + max_age=settings.session_ttl_days * 86400, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + ) + + +@router.post("/register", response_model=SessionRead, status_code=status.HTTP_201_CREATED) +async def register( + data: RegisterRequest, session: SessionDep, mailer: MailerDep, response: Response +) -> SessionRead: + user, token, expires_at = await auth_service.register( + session, + mailer, + email=data.email, + password=data.password, + display_name=data.display_name, + ) + _set_session_cookie(response, token) + return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at) + + +@router.post("/login", response_model=SessionRead) +async def login(data: LoginRequest, session: SessionDep, response: Response) -> SessionRead: + result = await auth_service.login(session, email=data.email, password=data.password) + if result is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials") + user, token, expires_at = result + _set_session_cookie(response, token) + return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(request: Request, session: SessionDep, response: Response) -> None: + raw_token = extract_session_token(request) + if raw_token: + await auth_service.logout(session, raw_token=raw_token) + response.delete_cookie(get_settings().cookie_name) + + +@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT) +async def verify_email(data: TokenRequest, session: SessionDep) -> None: + await auth_service.verify_email(session, raw_token=data.token) + + +@router.post("/request-password-reset", status_code=status.HTTP_202_ACCEPTED) +async def request_password_reset( + data: PasswordResetRequest, session: SessionDep, mailer: MailerDep +) -> dict: + await auth_service.request_password_reset(session, mailer, email=data.email) + return {"status": "accepted"} + + +@router.post("/reset-password", status_code=status.HTTP_204_NO_CONTENT) +async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> None: + await auth_service.reset_password( + session, raw_token=data.token, new_password=data.new_password + ) diff --git a/backend/app/api/v1/persons.py b/backend/app/api/v1/persons.py new file mode 100644 index 0000000..8624918 --- /dev/null +++ b/backend/app/api/v1/persons.py @@ -0,0 +1,43 @@ +import uuid + +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.person import PersonCreate, PersonRead +from app.services import person_service, tree_service + +# Persons are nested under their tree (the tenant boundary). +router = APIRouter(prefix="/trees", tags=["persons"]) + + +@router.post( + "/{tree_id}/persons", + response_model=PersonRead, + status_code=status.HTTP_201_CREATED, +) +async def create_person( + tree_id: uuid.UUID, data: PersonCreate, session: SessionDep, current: CurrentUser +) -> PersonRead: + # get_tree enforces existence + view access; create_person enforces edit rights. + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + person = await person_service.create_person( + session, + actor=current, + tree=tree, + given=data.given, + surname=data.surname, + gender=data.gender, + is_living=data.is_living, + privacy_setting=data.privacy, + notes=data.notes, + ) + return PersonRead.model_validate(person) + + +@router.get("/{tree_id}/persons", response_model=list[PersonRead]) +async def list_persons( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> list[PersonRead]: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree) + return [PersonRead.model_validate(p) for p in persons] diff --git a/backend/app/api/v1/trees.py b/backend/app/api/v1/trees.py new file mode 100644 index 0000000..05ea73b --- /dev/null +++ b/backend/app/api/v1/trees.py @@ -0,0 +1,33 @@ +import uuid + +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.tree import TreeCreate, TreeRead +from app.services import tree_service + +router = APIRouter(prefix="/trees", tags=["trees"]) + + +@router.post("", response_model=TreeRead, status_code=status.HTTP_201_CREATED) +async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUser) -> TreeRead: + tree = await tree_service.create_tree( + session, + owner=current, + name=data.name, + description=data.description, + visibility=data.visibility, + ) + return TreeRead.model_validate(tree) + + +@router.get("", response_model=list[TreeRead]) +async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]: + trees = await tree_service.list_trees_for_user(session, user=current) + return [TreeRead.model_validate(t) for t in trees] + + +@router.get("/{tree_id}", response_model=TreeRead) +async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + return TreeRead.model_validate(tree) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..af0f451 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from app.api.deps import CurrentUser +from app.schemas.user import UserRead + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/me", response_model=UserRead) +async def read_me(current: CurrentUser) -> UserRead: + return UserRead.model_validate(current) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..0a8aecb --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,49 @@ +"""Application configuration. + +Twelve-factor: everything is read from the environment. Defaults are +development-friendly; production supplies real values via the compose `.env`. +No secrets or endpoints are hard-coded. +""" + +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + app_name: str = "Provenance" + version: str = "0.0.0" + app_env: str = Field(default="development", description="development | production") + + # SQLAlchemy async URL, e.g. postgresql+asyncpg://user:pass@host:5432/db + database_url: str = Field( + default="postgresql+asyncpg://provenance:provenance@localhost:5432/provenance", + ) + + # --- Auth / sessions --- + session_ttl_days: int = 30 + token_ttl_hours: int = 24 # email-verify / password-reset token lifetime + cookie_name: str = "provenance_session" + cookie_secure: bool = True # set false for local http; true behind TLS + # Base URL used to build links in outbound email. + app_base_url: str = "http://localhost" + + # --- Email (SMTP) --- + mailer: str = Field(default="console", description="console | smtp") + smtp_host: str | None = None + smtp_port: int = 587 + smtp_username: str | None = None + smtp_password: str | None = None + smtp_from: str = "Provenance " + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/core/db.py b/backend/app/core/db.py new file mode 100644 index 0000000..a11feb0 --- /dev/null +++ b/backend/app/core/db.py @@ -0,0 +1,43 @@ +"""Async database engine, session factory, and the FastAPI session dependency. + +The repository layer builds on ``get_session``; ``get_engine`` also backs the +readiness probe. Everything is lazy so importing the app never opens a +connection (important for tests and for ``--help``-style invocations). +""" + +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.core.config import get_settings + +_engine: AsyncEngine | None = None +_sessionmaker: async_sessionmaker[AsyncSession] | None = None + + +def get_engine() -> AsyncEngine: + global _engine + if _engine is None: + _engine = create_async_engine(get_settings().database_url, pool_pre_ping=True) + return _engine + + +def get_sessionmaker() -> async_sessionmaker[AsyncSession]: + global _sessionmaker + if _sessionmaker is None: + _sessionmaker = async_sessionmaker( + get_engine(), expire_on_commit=False, class_=AsyncSession + ) + return _sessionmaker + + +async def get_session() -> AsyncIterator[AsyncSession]: + """FastAPI dependency. One session per request; commits are explicit in the + service layer.""" + async with get_sessionmaker()() as session: + yield session diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d74cbb9 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,35 @@ +"""Password hashing and token primitives. + +Passwords use Argon2id (argon2-cffi). Session and email tokens are random +high-entropy strings; only their SHA-256 hash is stored, so a database leak +never exposes a usable credential. +""" + +import hashlib +import secrets + +from argon2 import PasswordHasher +from argon2.exceptions import Argon2Error + +_hasher = PasswordHasher() + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + try: + return _hasher.verify(password_hash, password) + except (Argon2Error, ValueError): + return False + + +def generate_token() -> str: + """A URL-safe, high-entropy token (the raw secret handed to the client).""" + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + """SHA-256 of a token — what we store and look up by.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() diff --git a/backend/app/integrations/__init__.py b/backend/app/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/integrations/auth/__init__.py b/backend/app/integrations/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/integrations/auth/base.py b/backend/app/integrations/auth/base.py new file mode 100644 index 0000000..0115bd2 --- /dev/null +++ b/backend/app/integrations/auth/base.py @@ -0,0 +1,24 @@ +"""AuthProvider interface. + +Operators enable any subset of providers (local, OIDC, social). A provider's +job is narrow: verify a credential and return the matching User (or None). +Session issuance, tokens, and registration live in the auth service and are +provider-agnostic, so adding OIDC/social later (Phase 5) is additive. +""" + +from abc import ABC, abstractmethod + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User + + +class AuthProvider(ABC): + name: str + + @abstractmethod + async def authenticate( + self, session: AsyncSession, *, identifier: str, secret: str + ) -> User | None: + """Return the User if the credential is valid, else None.""" + raise NotImplementedError diff --git a/backend/app/integrations/auth/local.py b/backend/app/integrations/auth/local.py new file mode 100644 index 0000000..ad98acc --- /dev/null +++ b/backend/app/integrations/auth/local.py @@ -0,0 +1,27 @@ +"""Local (email + password) auth provider.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import verify_password +from app.integrations.auth.base import AuthProvider +from app.models.user import User + + +class LocalAuthProvider(AuthProvider): + name = "local" + + async def authenticate( + self, session: AsyncSession, *, identifier: str, secret: str + ) -> User | None: + email = identifier.strip().lower() + user = ( + await session.execute( + select(User).where(User.email == email, User.deleted_at.is_(None)) + ) + ).scalar_one_or_none() + if user is None or user.hashed_password is None: + return None + if not verify_password(user.hashed_password, secret): + return None + return user diff --git a/backend/app/integrations/mailer/__init__.py b/backend/app/integrations/mailer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/integrations/mailer/base.py b/backend/app/integrations/mailer/base.py new file mode 100644 index 0000000..c6749b2 --- /dev/null +++ b/backend/app/integrations/mailer/base.py @@ -0,0 +1,16 @@ +"""Mailer interface for transactional email. + +Implementations: ConsoleMailer (dev default — logs the link) and SMTPMailer +(operator-configured). Selected by config; resolved via app.api.deps.get_mailer. +Real deployments will move sending to the worker; for now it is inline. +""" + +from abc import ABC, abstractmethod + + +class Mailer(ABC): + @abstractmethod + async def send_email_verification(self, *, to: str, link: str) -> None: ... + + @abstractmethod + async def send_password_reset(self, *, to: str, link: str) -> None: ... diff --git a/backend/app/integrations/mailer/console.py b/backend/app/integrations/mailer/console.py new file mode 100644 index 0000000..6ee543a --- /dev/null +++ b/backend/app/integrations/mailer/console.py @@ -0,0 +1,16 @@ +"""Development mailer: logs the would-be email (including the action link) to +stdout instead of sending. Never use in production.""" + +import logging + +from app.integrations.mailer.base import Mailer + +logger = logging.getLogger("provenance.mailer") + + +class ConsoleMailer(Mailer): + async def send_email_verification(self, *, to: str, link: str) -> None: + logger.info("[email] verify address for %s -> %s", to, link) + + async def send_password_reset(self, *, to: str, link: str) -> None: + logger.info("[email] password reset for %s -> %s", to, link) diff --git a/backend/app/integrations/mailer/smtp.py b/backend/app/integrations/mailer/smtp.py new file mode 100644 index 0000000..8b3cff8 --- /dev/null +++ b/backend/app/integrations/mailer/smtp.py @@ -0,0 +1,43 @@ +"""SMTP mailer using the standard library, run off the event loop. Configured +entirely from settings (host/port/credentials/from).""" + +import asyncio +import smtplib +from email.message import EmailMessage + +from app.core.config import Settings +from app.integrations.mailer.base import Mailer + + +class SMTPMailer(Mailer): + def __init__(self, settings: Settings) -> None: + self.settings = settings + + def _send(self, *, to: str, subject: str, body: str) -> None: + msg = EmailMessage() + msg["From"] = self.settings.smtp_from + msg["To"] = to + msg["Subject"] = subject + msg.set_content(body) + with smtplib.SMTP(self.settings.smtp_host, self.settings.smtp_port) as smtp: + smtp.starttls() + if self.settings.smtp_username and self.settings.smtp_password: + smtp.login(self.settings.smtp_username, self.settings.smtp_password) + smtp.send_message(msg) + + async def _send_async(self, *, to: str, subject: str, body: str) -> None: + await asyncio.to_thread(self._send, to=to, subject=subject, body=body) + + async def send_email_verification(self, *, to: str, link: str) -> None: + await self._send_async( + to=to, + subject="Verify your Provenance email", + body=f"Confirm your email address:\n\n{link}\n", + ) + + async def send_password_reset(self, *, to: str, link: str) -> None: + await self._send_async( + to=to, + subject="Reset your Provenance password", + body=f"Reset your password:\n\n{link}\n", + ) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..af34fbf --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,61 @@ +"""FastAPI application entrypoint. + +Thin by design: wire settings, routers, and error handling, and expose the +OpenAPI contract. All domain logic lives in the service layer; the privacy +engine is the single enforcement point for reads. +""" + +import logging +import sys + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from app.api.health import router as health_router +from app.api.v1 import api_router +from app.core.config import get_settings +from app.services.exceptions import Conflict, Forbidden, NotFound + + +def _configure_logging() -> None: + """Emit the app's own ``provenance.*`` logs at INFO to stdout (uvicorn only + configures its own loggers). The ConsoleMailer relies on this so self-hosters + can read verification/reset links from the logs.""" + app_logger = logging.getLogger("provenance") + app_logger.setLevel(logging.INFO) + if not app_logger.handlers: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter("%(levelname)s [%(name)s] %(message)s")) + app_logger.addHandler(handler) + app_logger.propagate = False + + +def _register_error_handlers(app: FastAPI) -> None: + @app.exception_handler(NotFound) + async def _not_found(request: Request, exc: NotFound) -> JSONResponse: + return JSONResponse(status_code=404, content={"detail": str(exc) or "not found"}) + + @app.exception_handler(Forbidden) + async def _forbidden(request: Request, exc: Forbidden) -> JSONResponse: + return JSONResponse(status_code=403, content={"detail": str(exc) or "forbidden"}) + + @app.exception_handler(Conflict) + async def _conflict(request: Request, exc: Conflict) -> JSONResponse: + return JSONResponse(status_code=409, content={"detail": str(exc) or "conflict"}) + + +def create_app() -> FastAPI: + _configure_logging() + settings = get_settings() + app = FastAPI( + title=settings.app_name, + version=settings.version, + description="Provenance API — family and land provenance.", + ) + app.include_router(health_router) + app.include_router(api_router) + _register_error_handlers(app) + return app + + +app = create_app() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..dcc886e --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,31 @@ +"""Import every model so ``Base.metadata`` is complete for Alembic autogenerate +and for ``create_all`` in tests.""" + +from app.models.audit import AuditEntry +from app.models.auth import Session, UserToken +from app.models.base import Base +from app.models.event import Event +from app.models.person import Name, Person +from app.models.place import Place, PlaceName +from app.models.relationship import Relationship +from app.models.source import Citation, Source +from app.models.tree import Tree, TreeMembership +from app.models.user import User + +__all__ = [ + "Base", + "User", + "Tree", + "TreeMembership", + "Person", + "Name", + "Place", + "PlaceName", + "Relationship", + "Event", + "Source", + "Citation", + "AuditEntry", + "Session", + "UserToken", +] diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..792990a --- /dev/null +++ b/backend/app/models/audit.py @@ -0,0 +1,42 @@ +"""AuditEntry — append-only, immutable record of every mutation. + +The actor is a User, or the assistant principal acting *on behalf of* a User +(actor_type = assistant, actor_user_id = the user it serves). No timestamps +mixin and no soft delete: this table is never updated or deleted. +""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy import Enum as SAEnum +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.enums import AuditActorType +from app.models.mixins import UUIDPrimaryKey + + +class AuditEntry(Base, UUIDPrimaryKey): + __tablename__ = "audit_entries" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) + tree_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("trees.id", ondelete="SET NULL"), index=True + ) + actor_type: Mapped[AuditActorType] = mapped_column( + SAEnum(AuditActorType, name="audit_actor_type"), + default=AuditActorType.user, + server_default=AuditActorType.user.value, + ) + actor_user_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), index=True + ) + action: Mapped[str] = mapped_column(String(64)) # create | update | delete | restore | ... + entity_type: Mapped[str] = mapped_column(String(64)) + entity_id: Mapped[uuid.UUID | None] = mapped_column() + before: Mapped[dict | None] = mapped_column(JSONB) + after: Mapped[dict | None] = mapped_column(JSONB) diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py new file mode 100644 index 0000000..41e60d1 --- /dev/null +++ b/backend/app/models/auth.py @@ -0,0 +1,43 @@ +"""Authentication state: opaque backend-issued sessions and single-use email +tokens. Only token *hashes* are stored (see app.core.security). +""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.enums import TokenPurpose +from app.models.mixins import UUIDPrimaryKey + + +class Session(Base, UUIDPrimaryKey): + __tablename__ = "sessions" + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class UserToken(Base, UUIDPrimaryKey): + __tablename__ = "user_tokens" + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + purpose: Mapped[TokenPurpose] = mapped_column(SAEnum(TokenPurpose, name="token_purpose")) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..fd468bf --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,20 @@ +"""Declarative base with a stable constraint-naming convention. + +A fixed naming convention is important so Alembic generates deterministic, +human-readable names for indexes/constraints across migrations. +""" + +from sqlalchemy import MetaData +from sqlalchemy.orm import DeclarativeBase + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py new file mode 100644 index 0000000..41e5cb8 --- /dev/null +++ b/backend/app/models/enums.py @@ -0,0 +1,62 @@ +"""Closed-set enumerations that drive logic (authorization, privacy, traversal). + +Open-ended, GEDCOM-extensible vocabularies (event type, name type, source type) +are stored as strings instead, so importing real-world files never fails on an +unknown tag. +""" + +import enum + + +class TreeVisibility(enum.StrEnum): + public = "public" + unlisted = "unlisted" + private = "private" + + +class MembershipRole(enum.StrEnum): + owner = "owner" + editor = "editor" + viewer = "viewer" + + +class PersonPrivacy(enum.StrEnum): + """Per-person override of the tree's visibility (PRD US-041).""" + + inherit = "inherit" + private = "private" + public = "public" + + +class RelationshipType(enum.StrEnum): + parent_child = "parent_child" + partnership = "partnership" + sibling = "sibling" + + +class ParentChildQualifier(enum.StrEnum): + """Qualifies a parent_child edge so adoption/donor/blended families are + first-class rather than edge cases (ARCHITECTURE §5).""" + + biological = "biological" + adoptive = "adoptive" + step = "step" + foster = "foster" + donor = "donor" + guardian = "guardian" + + +class CitationConfidence(enum.StrEnum): + high = "high" + medium = "medium" + low = "low" + + +class AuditActorType(enum.StrEnum): + user = "user" + assistant = "assistant" + + +class TokenPurpose(enum.StrEnum): + email_verify = "email_verify" + password_reset = "password_reset" diff --git a/backend/app/models/event.py b/backend/app/models/event.py new file mode 100644 index 0000000..21f8102 --- /dev/null +++ b/backend/app/models/event.py @@ -0,0 +1,51 @@ +"""Event — a typed, dated, placed fact attached to a Person or a partnership. + +Genealogical dates are messy, so we keep both: +- ``date_value`` — the original string, verbatim (e.g. "ABT 1850", "BET 1850 AND + 1855"), for fidelity and GEDCOM round-trip. +- ``date_start`` / ``date_end`` — a normalized range for sorting and filtering + (an exact date sets start == end). +A CHECK enforces that exactly one subject (person XOR relationship) is set. +""" + +import uuid +from datetime import date + +from sqlalchemy import CheckConstraint, Date, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey + + +class Event(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "events" + __table_args__ = ( + CheckConstraint( + "(person_id IS NOT NULL) <> (relationship_id IS NOT NULL)", + name="subject_person_xor_relationship", + ), + ) + + # Open vocabulary (birth, death, marriage, residence, immigration, ...). + event_type: Mapped[str] = mapped_column(String(64), index=True) + + person_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("persons.id", ondelete="CASCADE"), index=True + ) + relationship_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("relationships.id", ondelete="CASCADE"), index=True + ) + place_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("places.id", ondelete="SET NULL"), index=True + ) + + date_value: Mapped[str | None] = mapped_column(String(255)) + date_start: Mapped[date | None] = mapped_column(Date) + date_end: Mapped[date | None] = mapped_column(Date) + date_precision: Mapped[str | None] = mapped_column(String(32)) # exact|about|before|after|range + calendar: Mapped[str] = mapped_column( + String(32), default="gregorian", server_default="gregorian" + ) + detail: Mapped[str | None] = mapped_column(String(512)) # e.g. occupation, address + notes: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/models/mixins.py b/backend/app/models/mixins.py new file mode 100644 index 0000000..b0dcbdb --- /dev/null +++ b/backend/app/models/mixins.py @@ -0,0 +1,45 @@ +"""Reusable column mixins. + +- ``UUIDPrimaryKey`` — UUID surrogate key (no PII in URLs; safe for multi-tenant). +- ``Timestamps`` — created/updated audit timestamps (DB-managed). +- ``SoftDelete`` — ``deleted_at``; a row is "deleted" when set. A scheduled + worker purges rows past the 30-day window (PRD US-080/081). +- ``TenantScoped`` — ``tree_id`` FK; every tree-owned row carries it so the + privacy engine can enforce isolation uniformly. +""" + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, declared_attr, mapped_column + + +class UUIDPrimaryKey: + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + + +class Timestamps: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class SoftDelete: + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) + + +class TenantScoped: + @declared_attr + def tree_id(cls) -> Mapped[uuid.UUID]: # noqa: N805 + return mapped_column( + ForeignKey("trees.id", ondelete="CASCADE"), nullable=False, index=True + ) diff --git a/backend/app/models/person.py b/backend/app/models/person.py new file mode 100644 index 0000000..d266482 --- /dev/null +++ b/backend/app/models/person.py @@ -0,0 +1,52 @@ +"""Person and Name. + +A Person carries living/deceased status and a per-person privacy override; the +display identity lives in one or more Name rows (variants, married names, +aliases) so name changes over time are first-class. +""" + +import uuid + +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.enums import PersonPrivacy +from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey + + +class Person(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "persons" + + # Free-form to stay inclusive; not a closed enum. + gender: Mapped[str | None] = mapped_column(String(32)) + # NULL = unknown (let the living-person rule derive it); True/False = asserted. + is_living: Mapped[bool | None] = mapped_column(Boolean) + privacy: Mapped[PersonPrivacy] = mapped_column( + SAEnum(PersonPrivacy, name="person_privacy"), + default=PersonPrivacy.inherit, + server_default=PersonPrivacy.inherit.value, + ) + notes: Mapped[str | None] = mapped_column(Text) + + +class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "names" + + person_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("persons.id", ondelete="CASCADE"), index=True + ) + # Open vocabulary (birth, married, alias, religious, ...) for GEDCOM fidelity. + name_type: Mapped[str] = mapped_column(String(32), default="birth", server_default="birth") + given: Mapped[str | None] = mapped_column(String(255)) + surname: Mapped[str | None] = mapped_column(String(255)) + prefix: Mapped[str | None] = mapped_column(String(64)) + suffix: Mapped[str | None] = mapped_column(String(64)) + nickname: Mapped[str | None] = mapped_column(String(128)) + # Original full form preserved verbatim (round-trip fidelity). + display_name: Mapped[str | None] = mapped_column(String(512)) + is_primary: Mapped[bool] = mapped_column( + Boolean, default=False, server_default=text("false") + ) + sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0") diff --git a/backend/app/models/place.py b/backend/app/models/place.py new file mode 100644 index 0000000..5b161fa --- /dev/null +++ b/backend/app/models/place.py @@ -0,0 +1,42 @@ +"""Place — a gazetteer entity — and PlaceName, its historical name variants. + +PlaceName carries date ranges so a record entered as "Königsberg, 1900" sorts +and displays correctly against "Kaliningrad" (ARCHITECTURE §5, §10). + +Phase 0 scopes Place to a Tree (``tree_id``) to keep tenant isolation absolute. +ARCHITECTURE calls the gazetteer "tenant-shared"; a deployment-wide shared +gazetteer is a deliberate later refinement (see ARCHITECTURE §5 note). +""" + +import uuid +from datetime import date + +from sqlalchemy import Date, Float, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey + + +class Place(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "places" + + name: Mapped[str] = mapped_column(String(512)) + # Self-referential hierarchy: place within place. + parent_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("places.id", ondelete="SET NULL"), index=True + ) + place_type: Mapped[str | None] = mapped_column(String(64)) + latitude: Mapped[float | None] = mapped_column(Float) + longitude: Mapped[float | None] = mapped_column(Float) + + +class PlaceName(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "place_names" + + place_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("places.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(512)) + valid_from: Mapped[date | None] = mapped_column(Date) + valid_to: Mapped[date | None] = mapped_column(Date) diff --git a/backend/app/models/relationship.py b/backend/app/models/relationship.py new file mode 100644 index 0000000..d9c7103 --- /dev/null +++ b/backend/app/models/relationship.py @@ -0,0 +1,40 @@ +"""Relationship — a typed, qualified edge between two Persons. + +Modeling parentage as qualified edges (rather than assuming two biological +parents) is what makes adoption, donor conception, and blended families +first-class. ``qualifier`` applies to parent_child edges; partnership events +(marriage, divorce) attach to the Relationship via Event.relationship_id. +""" + +import uuid + +from sqlalchemy import CheckConstraint, ForeignKey, Text +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.enums import ParentChildQualifier, RelationshipType +from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey + + +class Relationship(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "relationships" + __table_args__ = ( + CheckConstraint("person_from_id <> person_to_id", name="different_persons"), + ) + + type: Mapped[RelationshipType] = mapped_column( + SAEnum(RelationshipType, name="relationship_type") + ) + # For parent_child: from = parent, to = child. For partnership/sibling: symmetric. + person_from_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("persons.id", ondelete="CASCADE"), index=True + ) + person_to_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("persons.id", ondelete="CASCADE"), index=True + ) + # Only meaningful for parent_child edges. + qualifier: Mapped[ParentChildQualifier | None] = mapped_column( + SAEnum(ParentChildQualifier, name="parent_child_qualifier") + ) + notes: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/models/source.py b/backend/app/models/source.py new file mode 100644 index 0000000..7416491 --- /dev/null +++ b/backend/app/models/source.py @@ -0,0 +1,66 @@ +"""Source and Citation — the first-class provenance spine. + +A Source is a reusable record of an origin; a Citation links one Source to one +specific fact (a Person, Name, Event, or Relationship — and OwnershipEvent once +property lands). A CHECK enforces exactly one target so a citation always points +at a single fact. +""" + +import uuid + +from sqlalchemy import CheckConstraint, ForeignKey, String, Text +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.enums import CitationConfidence +from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey + + +class Source(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "sources" + + title: Mapped[str] = mapped_column(String(512)) + author: Mapped[str | None] = mapped_column(String(255)) + source_type: Mapped[str | None] = mapped_column(String(64)) # book, census, deed, ... + repository: Mapped[str | None] = mapped_column(String(255)) + url: Mapped[str | None] = mapped_column(String(1024)) + citation_text: Mapped[str | None] = mapped_column(Text) + publication_info: Mapped[str | None] = mapped_column(Text) + quality_note: Mapped[str | None] = mapped_column(String(255)) + + +class Citation(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete): + __tablename__ = "citations" + __table_args__ = ( + CheckConstraint( + "(person_id IS NOT NULL)::int + (event_id IS NOT NULL)::int " + "+ (name_id IS NOT NULL)::int + (relationship_id IS NOT NULL)::int = 1", + name="exactly_one_target", + ), + ) + + source_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("sources.id", ondelete="CASCADE"), index=True + ) + + # Exactly one of these is set (see CHECK above). + person_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("persons.id", ondelete="CASCADE"), index=True + ) + event_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("events.id", ondelete="CASCADE"), index=True + ) + name_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("names.id", ondelete="CASCADE"), index=True + ) + relationship_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("relationships.id", ondelete="CASCADE"), index=True + ) + + # Locality within the source. + page: Mapped[str | None] = mapped_column(String(255)) + detail: Mapped[str | None] = mapped_column(Text) # entry, line, free notes + confidence: Mapped[CitationConfidence | None] = mapped_column( + SAEnum(CitationConfidence, name="citation_confidence") + ) diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py new file mode 100644 index 0000000..8581d69 --- /dev/null +++ b/backend/app/models/tree.py @@ -0,0 +1,43 @@ +"""Tree — the top-level tenant boundary for genealogical data — and +TreeMembership, the basis for authorization (ARCHITECTURE §5). +""" + +import uuid + +from sqlalchemy import Enum as SAEnum +from sqlalchemy import ForeignKey, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.enums import MembershipRole, TreeVisibility +from app.models.mixins import SoftDelete, Timestamps, UUIDPrimaryKey + + +class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete): + __tablename__ = "trees" + + owner_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="RESTRICT"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + description: Mapped[str | None] = mapped_column(Text) + visibility: Mapped[TreeVisibility] = mapped_column( + SAEnum(TreeVisibility, name="tree_visibility"), + default=TreeVisibility.private, + server_default=TreeVisibility.private.value, + ) + + +class TreeMembership(Base, UUIDPrimaryKey, Timestamps): + __tablename__ = "tree_memberships" + __table_args__ = ( + UniqueConstraint("tree_id", "user_id", name="uq_tree_memberships_tree_user"), + ) + + tree_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("trees.id", ondelete="CASCADE"), index=True + ) + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + role: Mapped[MembershipRole] = mapped_column(SAEnum(MembershipRole, name="membership_role")) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..16f70b7 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,21 @@ +"""User — a person with login. Identity is internal so one user can link +multiple auth providers later (the provider-link table arrives with the auth +slice). ``hashed_password`` is nullable: external/OIDC users have none. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base +from app.models.mixins import SoftDelete, Timestamps, UUIDPrimaryKey + + +class User(Base, UUIDPrimaryKey, Timestamps, SoftDelete): + __tablename__ = "users" + + email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + display_name: Mapped[str | None] = mapped_column(String(255)) + hashed_password: Mapped[str | None] = mapped_column(String(255)) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/repositories/base.py b/backend/app/repositories/base.py new file mode 100644 index 0000000..dac057a --- /dev/null +++ b/backend/app/repositories/base.py @@ -0,0 +1,40 @@ +"""Thin data-access layer over SQLAlchemy. No business rules live here — the +service layer owns those (and the privacy engine). The repository only knows how +to fetch and stage rows, transparently excluding soft-deleted ones. +""" + +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +class BaseRepository: + def __init__(self, session: AsyncSession, model: type) -> None: + self.session = session + self.model = model + + def _exclude_deleted(self, stmt): + if hasattr(self.model, "deleted_at"): + stmt = stmt.where(self.model.deleted_at.is_(None)) + return stmt + + async def get(self, id_: Any, *, include_deleted: bool = False): + stmt = select(self.model).where(self.model.id == id_) + if not include_deleted: + stmt = self._exclude_deleted(stmt) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list(self, *conditions, include_deleted: bool = False, order_by=None): + stmt = select(self.model) + for condition in conditions: + stmt = stmt.where(condition) + if not include_deleted: + stmt = self._exclude_deleted(stmt) + if order_by is not None: + stmt = stmt.order_by(order_by) + return list((await self.session.execute(stmt)).scalars().all()) + + def add(self, obj): + self.session.add(obj) + return obj diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..f3cb7b0 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.schemas.user import UserRead + + +class RegisterRequest(BaseModel): + email: str + password: str = Field(min_length=8) + display_name: str | None = None + + +class LoginRequest(BaseModel): + email: str + password: str + + +class TokenRequest(BaseModel): + token: str + + +class PasswordResetRequest(BaseModel): + email: str + + +class PasswordResetConfirm(BaseModel): + token: str + new_password: str = Field(min_length=8) + + +class SessionRead(BaseModel): + user: UserRead + token: str + expires_at: datetime diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py new file mode 100644 index 0000000..55eb3f3 --- /dev/null +++ b/backend/app/schemas/person.py @@ -0,0 +1,27 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.enums import PersonPrivacy + + +class PersonCreate(BaseModel): + given: str | None = None + surname: str | None = None + gender: str | None = None + is_living: bool | None = None + privacy: PersonPrivacy = PersonPrivacy.inherit + notes: str | None = None + + +class PersonRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + tree_id: uuid.UUID + primary_name: str | None = None + gender: str | None + is_living: bool | None + privacy: PersonPrivacy + created_at: datetime diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py new file mode 100644 index 0000000..31007ee --- /dev/null +++ b/backend/app/schemas/tree.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.enums import TreeVisibility + + +class TreeCreate(BaseModel): + name: str + description: str | None = None + visibility: TreeVisibility = TreeVisibility.private + + +class TreeRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + description: str | None + visibility: TreeVisibility + owner_id: uuid.UUID + created_at: datetime diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..0535f73 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +# Note: email is a plain str for now (no email-validator dependency yet); the +# auth slice can tighten this to EmailStr. + + +class UserCreate(BaseModel): + email: str + display_name: str | None = None + + +class UserRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: str + display_name: str | None + email_verified_at: datetime | None + created_at: datetime diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..0c87831 --- /dev/null +++ b/backend/app/services/audit.py @@ -0,0 +1,37 @@ +"""Audit logging. Every mutation records an append-only AuditEntry attributing +the change to a User (or the assistant principal acting for a User). Staged on +the session; the caller commits as part of its unit of work. +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.audit import AuditEntry +from app.models.enums import AuditActorType + + +def record_audit( + session: AsyncSession, + *, + action: str, + entity_type: str, + entity_id: uuid.UUID | None = None, + tree_id: uuid.UUID | None = None, + actor_user_id: uuid.UUID | None = None, + actor_type: AuditActorType = AuditActorType.user, + before: dict | None = None, + after: dict | None = None, +) -> AuditEntry: + entry = AuditEntry( + action=action, + entity_type=entity_type, + entity_id=entity_id, + tree_id=tree_id, + actor_user_id=actor_user_id, + actor_type=actor_type, + before=before, + after=after, + ) + session.add(entry) + return entry diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..57aeaa7 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,201 @@ +"""Authentication service: registration, login, sessions, email verification, +and password reset. Provider-agnostic — credential checking is delegated to an +AuthProvider; this module owns session/token issuance and the audit trail. +""" + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.security import generate_token, hash_password, hash_token +from app.integrations.auth.local import LocalAuthProvider +from app.integrations.mailer.base import Mailer +from app.models.auth import Session as SessionModel +from app.models.auth import UserToken +from app.models.enums import TokenPurpose +from app.models.user import User +from app.services.audit import record_audit +from app.services.exceptions import Conflict, NotFound + +_local_provider = LocalAuthProvider() + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _link(path: str, raw: str) -> str: + return f"{get_settings().app_base_url}{path}?token={raw}" + + +def _issue_session(session: AsyncSession, user: User) -> tuple[str, SessionModel]: + raw = generate_token() + record = SessionModel( + user_id=user.id, + token_hash=hash_token(raw), + expires_at=_now() + timedelta(days=get_settings().session_ttl_days), + ) + session.add(record) + return raw, record + + +def _create_email_token(session: AsyncSession, user: User, purpose: TokenPurpose) -> str: + raw = generate_token() + session.add( + UserToken( + user_id=user.id, + purpose=purpose, + token_hash=hash_token(raw), + expires_at=_now() + timedelta(hours=get_settings().token_ttl_hours), + ) + ) + return raw + + +async def _consume_token( + session: AsyncSession, raw_token: str, purpose: TokenPurpose +) -> UserToken: + token = ( + await session.execute( + select(UserToken).where( + UserToken.token_hash == hash_token(raw_token), + UserToken.purpose == purpose, + ) + ) + ).scalar_one_or_none() + if token is None or token.used_at is not None or token.expires_at <= _now(): + raise NotFound("invalid or expired token") + token.used_at = _now() + return token + + +async def register( + session: AsyncSession, + mailer: Mailer, + *, + email: str, + password: str, + display_name: str | None = None, +) -> tuple[User, str, datetime]: + email = email.strip().lower() + existing = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + if existing is not None: + raise Conflict("email already registered") + + user = User(email=email, display_name=display_name, hashed_password=hash_password(password)) + session.add(user) + await session.flush() + + verify_raw = _create_email_token(session, user, TokenPurpose.email_verify) + raw_token, record = _issue_session(session, user) + record_audit( + session, + action="register", + entity_type="User", + entity_id=user.id, + actor_user_id=user.id, + after={"email": email}, + ) + await session.commit() + await session.refresh(user) + + await mailer.send_email_verification(to=email, link=_link("/auth/verify-email", verify_raw)) + return user, raw_token, record.expires_at + + +async def login( + session: AsyncSession, *, email: str, password: str +) -> tuple[User, str, datetime] | None: + user = await _local_provider.authenticate(session, identifier=email, secret=password) + if user is None: + return None + raw_token, record = _issue_session(session, user) + record_audit( + session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id + ) + await session.commit() + return user, raw_token, record.expires_at + + +async def logout(session: AsyncSession, *, raw_token: str) -> None: + await session.execute( + update(SessionModel) + .where( + SessionModel.token_hash == hash_token(raw_token), + SessionModel.revoked_at.is_(None), + ) + .values(revoked_at=_now()) + ) + await session.commit() + + +async def resolve_session_user(session: AsyncSession, *, raw_token: str) -> User | None: + record = ( + await session.execute( + select(SessionModel).where(SessionModel.token_hash == hash_token(raw_token)) + ) + ).scalar_one_or_none() + if record is None or record.revoked_at is not None or record.expires_at <= _now(): + return None + return ( + await session.execute( + select(User).where(User.id == record.user_id, User.deleted_at.is_(None)) + ) + ).scalar_one_or_none() + + +async def verify_email(session: AsyncSession, *, raw_token: str) -> None: + token = await _consume_token(session, raw_token, TokenPurpose.email_verify) + await session.execute( + update(User).where(User.id == token.user_id).values(email_verified_at=_now()) + ) + record_audit( + session, + action="verify_email", + entity_type="User", + entity_id=token.user_id, + actor_user_id=token.user_id, + ) + await session.commit() + + +async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email: str) -> None: + email = email.strip().lower() + user = ( + await session.execute( + select(User).where(User.email == email, User.deleted_at.is_(None)) + ) + ).scalar_one_or_none() + # Always succeed to avoid leaking which emails are registered. + if user is None: + return + raw = _create_email_token(session, user, TokenPurpose.password_reset) + await session.commit() + await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw)) + + +async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None: + token = await _consume_token(session, raw_token, TokenPurpose.password_reset) + await session.execute( + update(User) + .where(User.id == token.user_id) + .values(hashed_password=hash_password(new_password)) + ) + # Revoke all existing sessions — a reset invalidates prior logins. + await session.execute( + update(SessionModel) + .where(SessionModel.user_id == token.user_id, SessionModel.revoked_at.is_(None)) + .values(revoked_at=_now()) + ) + record_audit( + session, + action="reset_password", + entity_type="User", + entity_id=token.user_id, + actor_user_id=token.user_id, + ) + await session.commit() diff --git a/backend/app/services/exceptions.py b/backend/app/services/exceptions.py new file mode 100644 index 0000000..3268abd --- /dev/null +++ b/backend/app/services/exceptions.py @@ -0,0 +1,18 @@ +"""Domain errors. The API layer maps these to HTTP status codes so services +stay transport-agnostic.""" + + +class DomainError(Exception): + """Base for domain-level errors.""" + + +class NotFound(DomainError): + """Requested entity does not exist (or is soft-deleted / not visible).""" + + +class Forbidden(DomainError): + """Caller lacks the required role for this action.""" + + +class Conflict(DomainError): + """Operation conflicts with current state (e.g. duplicate email).""" diff --git a/backend/app/services/person_service.py b/backend/app/services/person_service.py new file mode 100644 index 0000000..320deb1 --- /dev/null +++ b/backend/app/services/person_service.py @@ -0,0 +1,113 @@ +"""Person service. Writes require editor rights on the tree; reads run every +person through the privacy engine. Each returned Person gets a transient +``primary_name`` for display (not persisted). +""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import PersonPrivacy +from app.models.person import Name, Person +from app.models.tree import Tree +from app.models.user import User +from app.services import privacy +from app.services.audit import record_audit +from app.services.exceptions import Forbidden +from app.services.privacy import Visibility + + +def _format_name(name: Name) -> str | None: + parts = [name.given, name.surname] + joined = " ".join(p for p in parts if p) + return joined or name.display_name + + +async def _attach_primary_name(session: AsyncSession, person: Person) -> None: + stmt = ( + select(Name) + .where(Name.person_id == person.id, Name.deleted_at.is_(None)) + .order_by(Name.is_primary.desc(), Name.sort_order) + ) + name = (await session.execute(stmt)).scalars().first() + # Transient display attribute consumed by the PersonRead schema. + person.primary_name = _format_name(name) if name is not None else None + + +async def create_person( + session: AsyncSession, + *, + actor: User, + tree: Tree, + given: str | None = None, + surname: str | None = None, + gender: str | None = None, + is_living: bool | None = None, + privacy_setting: PersonPrivacy = PersonPrivacy.inherit, + notes: str | None = None, +) -> Person: + if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): + raise Forbidden("not an editor of this tree") + + person = Person( + tree_id=tree.id, + gender=gender, + is_living=is_living, + privacy=privacy_setting, + notes=notes, + ) + session.add(person) + await session.flush() # assign person.id + + if given or surname: + session.add( + Name( + tree_id=tree.id, + person_id=person.id, + name_type="birth", + given=given, + surname=surname, + is_primary=True, + ) + ) + record_audit( + session, + action="create", + entity_type="Person", + entity_id=person.id, + tree_id=tree.id, + actor_user_id=actor.id, + after={"given": given, "surname": surname}, + ) + await session.commit() + await session.refresh(person) + await _attach_primary_name(session, person) + return person + + +async def list_persons( + session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree +) -> list[Person]: + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + + stmt = ( + select(Person) + .where(Person.tree_id == tree.id, Person.deleted_at.is_(None)) + .order_by(Person.created_at) + ) + persons = list((await session.execute(stmt)).scalars().all()) + + visible: list[Person] = [] + for person in persons: + if ( + await privacy.person_visibility( + session, user_id=viewer_id, tree=tree, person=person + ) + == Visibility.hidden + ): + continue + await _attach_primary_name(session, person) + visible.append(person) + return visible diff --git a/backend/app/services/privacy.py b/backend/app/services/privacy.py new file mode 100644 index 0000000..e077af0 --- /dev/null +++ b/backend/app/services/privacy.py @@ -0,0 +1,62 @@ +"""The privacy engine — the single enforcement point for visibility. + +INVARIANT (CLAUDE.md #2): every read path resolves visibility here. Do not add a +query path that returns rows to a caller without first passing through this +module. Effective visibility is a function of the viewer's role on the tree, the +tree's visibility, the per-person override, and (Phase 2) living-person status. +""" + +import enum +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility +from app.models.person import Person +from app.models.tree import Tree, TreeMembership + + +class Visibility(enum.StrEnum): + full = "full" + redacted = "redacted" + hidden = "hidden" + + +async def get_membership_role( + session: AsyncSession, user_id: uuid.UUID | None, tree_id: uuid.UUID +) -> MembershipRole | None: + if user_id is None: + return None + stmt = select(TreeMembership.role).where( + TreeMembership.tree_id == tree_id, + TreeMembership.user_id == user_id, + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool: + if tree.deleted_at is not None: + return False + if await get_membership_role(session, user_id, tree.id) is not None: + return True + return tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted) + + +async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool: + role = await get_membership_role(session, user_id, tree.id) + return role in (MembershipRole.owner, MembershipRole.editor) + + +async def person_visibility( + session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person +) -> Visibility: + if not await can_view_tree(session, user_id=user_id, tree=tree): + return Visibility.hidden + if await get_membership_role(session, user_id, tree.id) is not None: + return Visibility.full + # Non-member viewing a public/unlisted tree: + if person.privacy == PersonPrivacy.private: + return Visibility.hidden + # TODO(Phase 2): redact living people for non-members (ARCHITECTURE §6). + return Visibility.full diff --git a/backend/app/services/tree_service.py b/backend/app/services/tree_service.py new file mode 100644 index 0000000..ffc4c59 --- /dev/null +++ b/backend/app/services/tree_service.py @@ -0,0 +1,61 @@ +"""Tree service. Creating a tree also creates the owner's TreeMembership (the +authorization basis) and an audit entry. Reads go through the privacy engine. +""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import MembershipRole, TreeVisibility +from app.models.tree import Tree, TreeMembership +from app.models.user import User +from app.repositories.base import BaseRepository +from app.services import privacy +from app.services.audit import record_audit +from app.services.exceptions import Forbidden, NotFound + + +async def create_tree( + session: AsyncSession, + *, + owner: User, + name: str, + description: str | None = None, + visibility: TreeVisibility = TreeVisibility.private, +) -> Tree: + tree = Tree(owner_id=owner.id, name=name, description=description, visibility=visibility) + session.add(tree) + await session.flush() # assign tree.id + session.add(TreeMembership(tree_id=tree.id, user_id=owner.id, role=MembershipRole.owner)) + record_audit( + session, + action="create", + entity_type="Tree", + entity_id=tree.id, + tree_id=tree.id, + actor_user_id=owner.id, + after={"name": name, "visibility": visibility.value}, + ) + await session.commit() + await session.refresh(tree) + return tree + + +async def list_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]: + stmt = ( + select(Tree) + .join(TreeMembership, TreeMembership.tree_id == Tree.id) + .where(TreeMembership.user_id == user.id, Tree.deleted_at.is_(None)) + .order_by(Tree.created_at) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid.UUID) -> Tree: + tree = await BaseRepository(session, Tree).get(tree_id) + if tree is None: + raise NotFound("tree not found") + if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): + raise Forbidden("not permitted to view this tree") + return tree diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..61c4498 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,44 @@ +"""User service. Account creation here is a temporary, open dev bootstrap so we +can create tree owners before the auth slice exists; the auth slice replaces it +with the AuthProvider (password/OIDC/social) and proper verification. +""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.repositories.base import BaseRepository +from app.services.audit import record_audit +from app.services.exceptions import Conflict + + +async def create_user( + session: AsyncSession, *, email: str, display_name: str | None = None +) -> User: + email = email.strip().lower() + existing = ( + await session.execute(select(User).where(User.email == email)) + ).scalar_one_or_none() + if existing is not None: + raise Conflict("email already registered") + + user = User(email=email, display_name=display_name) + session.add(user) + await session.flush() # assign user.id + record_audit( + session, + action="create", + entity_type="User", + entity_id=user.id, + actor_user_id=user.id, + after={"email": email}, + ) + await session.commit() + await session.refresh(user) + return user + + +async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None: + return await BaseRepository(session, User).get(user_id) diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..3a43676 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,59 @@ +"""Alembic environment — async, URL sourced from settings (DATABASE_URL).""" + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.core.config import get_settings +from app.models import Base # noqa: F401 — importing registers all models + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Inject the runtime database URL (asyncpg driver) from the environment. +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def _do_run_migrations(connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(_do_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..edb0604 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/1f6e54f6406a_auth_sessions_and_tokens.py b/backend/migrations/versions/1f6e54f6406a_auth_sessions_and_tokens.py new file mode 100644 index 0000000..876eaae --- /dev/null +++ b/backend/migrations/versions/1f6e54f6406a_auth_sessions_and_tokens.py @@ -0,0 +1,62 @@ +"""auth sessions and tokens + +Revision ID: 1f6e54f6406a +Revises: ec43c338e155 +Create Date: 2026-06-06 10:47:06.454748 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1f6e54f6406a' +down_revision: str | None = 'ec43c338e155' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('sessions', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_sessions_user_id_users'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_sessions')) + ) + op.create_index(op.f('ix_sessions_token_hash'), 'sessions', ['token_hash'], unique=True) + op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False) + op.create_table('user_tokens', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('purpose', sa.Enum('email_verify', 'password_reset', name='token_purpose'), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_tokens_user_id_users'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user_tokens')) + ) + op.create_index(op.f('ix_user_tokens_token_hash'), 'user_tokens', ['token_hash'], unique=True) + op.create_index(op.f('ix_user_tokens_user_id'), 'user_tokens', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_tokens_user_id'), table_name='user_tokens') + op.drop_index(op.f('ix_user_tokens_token_hash'), table_name='user_tokens') + op.drop_table('user_tokens') + op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions') + op.drop_index(op.f('ix_sessions_token_hash'), table_name='sessions') + op.drop_table('sessions') + # ### end Alembic commands ### + + # Enum type created implicitly by create_table(); drop it for reversibility. + op.execute("DROP TYPE IF EXISTS token_purpose") diff --git a/backend/migrations/versions/ec43c338e155_core_data_model.py b/backend/migrations/versions/ec43c338e155_core_data_model.py new file mode 100644 index 0000000..d152d19 --- /dev/null +++ b/backend/migrations/versions/ec43c338e155_core_data_model.py @@ -0,0 +1,304 @@ +"""core data model + +Revision ID: ec43c338e155 +Revises: +Create Date: 2026-06-06 10:27:41.671787 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'ec43c338e155' +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('email', sa.String(length=320), nullable=False), + sa.Column('email_verified_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('display_name', sa.String(length=255), nullable=True), + sa.Column('hashed_password', sa.String(length=255), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_users')) + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('trees', + sa.Column('owner_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('visibility', sa.Enum('public', 'unlisted', 'private', name='tree_visibility'), server_default='private', nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], name=op.f('fk_trees_owner_id_users'), ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_trees')) + ) + op.create_index(op.f('ix_trees_owner_id'), 'trees', ['owner_id'], unique=False) + op.create_table('audit_entries', + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=True), + sa.Column('actor_type', sa.Enum('user', 'assistant', name='audit_actor_type'), server_default='user', nullable=False), + sa.Column('actor_user_id', sa.Uuid(), nullable=True), + sa.Column('action', sa.String(length=64), nullable=False), + sa.Column('entity_type', sa.String(length=64), nullable=False), + sa.Column('entity_id', sa.Uuid(), nullable=True), + sa.Column('before', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('after', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], name=op.f('fk_audit_entries_actor_user_id_users'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_audit_entries_tree_id_trees'), ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_entries')) + ) + op.create_index(op.f('ix_audit_entries_actor_user_id'), 'audit_entries', ['actor_user_id'], unique=False) + op.create_index(op.f('ix_audit_entries_created_at'), 'audit_entries', ['created_at'], unique=False) + op.create_index(op.f('ix_audit_entries_tree_id'), 'audit_entries', ['tree_id'], unique=False) + op.create_table('persons', + sa.Column('gender', sa.String(length=32), nullable=True), + sa.Column('is_living', sa.Boolean(), nullable=True), + sa.Column('privacy', sa.Enum('inherit', 'private', 'public', name='person_privacy'), server_default='inherit', nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_persons_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_persons')) + ) + op.create_index(op.f('ix_persons_tree_id'), 'persons', ['tree_id'], unique=False) + op.create_table('places', + sa.Column('name', sa.String(length=512), nullable=False), + sa.Column('parent_id', sa.Uuid(), nullable=True), + sa.Column('place_type', sa.String(length=64), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['parent_id'], ['places.id'], name=op.f('fk_places_parent_id_places'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_places_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_places')) + ) + op.create_index(op.f('ix_places_parent_id'), 'places', ['parent_id'], unique=False) + op.create_index(op.f('ix_places_tree_id'), 'places', ['tree_id'], unique=False) + op.create_table('sources', + sa.Column('title', sa.String(length=512), nullable=False), + sa.Column('author', sa.String(length=255), nullable=True), + sa.Column('source_type', sa.String(length=64), nullable=True), + sa.Column('repository', sa.String(length=255), nullable=True), + sa.Column('url', sa.String(length=1024), nullable=True), + sa.Column('citation_text', sa.Text(), nullable=True), + sa.Column('publication_info', sa.Text(), nullable=True), + sa.Column('quality_note', sa.String(length=255), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_sources_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_sources')) + ) + op.create_index(op.f('ix_sources_tree_id'), 'sources', ['tree_id'], unique=False) + op.create_table('tree_memberships', + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('role', sa.Enum('owner', 'editor', 'viewer', name='membership_role'), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_tree_memberships_tree_id_trees'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_tree_memberships_user_id_users'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_tree_memberships')), + sa.UniqueConstraint('tree_id', 'user_id', name='uq_tree_memberships_tree_user') + ) + op.create_index(op.f('ix_tree_memberships_tree_id'), 'tree_memberships', ['tree_id'], unique=False) + op.create_index(op.f('ix_tree_memberships_user_id'), 'tree_memberships', ['user_id'], unique=False) + op.create_table('names', + sa.Column('person_id', sa.Uuid(), nullable=False), + sa.Column('name_type', sa.String(length=32), server_default='birth', nullable=False), + sa.Column('given', sa.String(length=255), nullable=True), + sa.Column('surname', sa.String(length=255), nullable=True), + sa.Column('prefix', sa.String(length=64), nullable=True), + sa.Column('suffix', sa.String(length=64), nullable=True), + sa.Column('nickname', sa.String(length=128), nullable=True), + sa.Column('display_name', sa.String(length=512), nullable=True), + sa.Column('is_primary', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_names_person_id_persons'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_names_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_names')) + ) + op.create_index(op.f('ix_names_person_id'), 'names', ['person_id'], unique=False) + op.create_index(op.f('ix_names_tree_id'), 'names', ['tree_id'], unique=False) + op.create_table('place_names', + sa.Column('place_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=512), nullable=False), + sa.Column('valid_from', sa.Date(), nullable=True), + sa.Column('valid_to', sa.Date(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['place_id'], ['places.id'], name=op.f('fk_place_names_place_id_places'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_place_names_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_place_names')) + ) + op.create_index(op.f('ix_place_names_place_id'), 'place_names', ['place_id'], unique=False) + op.create_index(op.f('ix_place_names_tree_id'), 'place_names', ['tree_id'], unique=False) + op.create_table('relationships', + sa.Column('type', sa.Enum('parent_child', 'partnership', 'sibling', name='relationship_type'), nullable=False), + sa.Column('person_from_id', sa.Uuid(), nullable=False), + sa.Column('person_to_id', sa.Uuid(), nullable=False), + sa.Column('qualifier', sa.Enum('biological', 'adoptive', 'step', 'foster', 'donor', 'guardian', name='parent_child_qualifier'), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint('person_from_id <> person_to_id', name=op.f('ck_relationships_different_persons')), + sa.ForeignKeyConstraint(['person_from_id'], ['persons.id'], name=op.f('fk_relationships_person_from_id_persons'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['person_to_id'], ['persons.id'], name=op.f('fk_relationships_person_to_id_persons'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_relationships_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_relationships')) + ) + op.create_index(op.f('ix_relationships_person_from_id'), 'relationships', ['person_from_id'], unique=False) + op.create_index(op.f('ix_relationships_person_to_id'), 'relationships', ['person_to_id'], unique=False) + op.create_index(op.f('ix_relationships_tree_id'), 'relationships', ['tree_id'], unique=False) + op.create_table('events', + sa.Column('event_type', sa.String(length=64), nullable=False), + sa.Column('person_id', sa.Uuid(), nullable=True), + sa.Column('relationship_id', sa.Uuid(), nullable=True), + sa.Column('place_id', sa.Uuid(), nullable=True), + sa.Column('date_value', sa.String(length=255), nullable=True), + sa.Column('date_start', sa.Date(), nullable=True), + sa.Column('date_end', sa.Date(), nullable=True), + sa.Column('date_precision', sa.String(length=32), nullable=True), + sa.Column('calendar', sa.String(length=32), server_default='gregorian', nullable=False), + sa.Column('detail', sa.String(length=512), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint('(person_id IS NOT NULL) <> (relationship_id IS NOT NULL)', name=op.f('ck_events_subject_person_xor_relationship')), + sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_events_person_id_persons'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['place_id'], ['places.id'], name=op.f('fk_events_place_id_places'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['relationship_id'], ['relationships.id'], name=op.f('fk_events_relationship_id_relationships'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_events_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_events')) + ) + op.create_index(op.f('ix_events_event_type'), 'events', ['event_type'], unique=False) + op.create_index(op.f('ix_events_person_id'), 'events', ['person_id'], unique=False) + op.create_index(op.f('ix_events_place_id'), 'events', ['place_id'], unique=False) + op.create_index(op.f('ix_events_relationship_id'), 'events', ['relationship_id'], unique=False) + op.create_index(op.f('ix_events_tree_id'), 'events', ['tree_id'], unique=False) + op.create_table('citations', + sa.Column('source_id', sa.Uuid(), nullable=False), + sa.Column('person_id', sa.Uuid(), nullable=True), + sa.Column('event_id', sa.Uuid(), nullable=True), + sa.Column('name_id', sa.Uuid(), nullable=True), + sa.Column('relationship_id', sa.Uuid(), nullable=True), + sa.Column('page', sa.String(length=255), nullable=True), + sa.Column('detail', sa.Text(), nullable=True), + sa.Column('confidence', sa.Enum('high', 'medium', 'low', name='citation_confidence'), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('tree_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint('(person_id IS NOT NULL)::int + (event_id IS NOT NULL)::int + (name_id IS NOT NULL)::int + (relationship_id IS NOT NULL)::int = 1', name=op.f('ck_citations_exactly_one_target')), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_citations_event_id_events'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['name_id'], ['names.id'], name=op.f('fk_citations_name_id_names'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_citations_person_id_persons'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['relationship_id'], ['relationships.id'], name=op.f('fk_citations_relationship_id_relationships'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_citations_source_id_sources'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_citations_tree_id_trees'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_citations')) + ) + op.create_index(op.f('ix_citations_event_id'), 'citations', ['event_id'], unique=False) + op.create_index(op.f('ix_citations_name_id'), 'citations', ['name_id'], unique=False) + op.create_index(op.f('ix_citations_person_id'), 'citations', ['person_id'], unique=False) + op.create_index(op.f('ix_citations_relationship_id'), 'citations', ['relationship_id'], unique=False) + op.create_index(op.f('ix_citations_source_id'), 'citations', ['source_id'], unique=False) + op.create_index(op.f('ix_citations_tree_id'), 'citations', ['tree_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_citations_tree_id'), table_name='citations') + op.drop_index(op.f('ix_citations_source_id'), table_name='citations') + op.drop_index(op.f('ix_citations_relationship_id'), table_name='citations') + op.drop_index(op.f('ix_citations_person_id'), table_name='citations') + op.drop_index(op.f('ix_citations_name_id'), table_name='citations') + op.drop_index(op.f('ix_citations_event_id'), table_name='citations') + op.drop_table('citations') + op.drop_index(op.f('ix_events_tree_id'), table_name='events') + op.drop_index(op.f('ix_events_relationship_id'), table_name='events') + op.drop_index(op.f('ix_events_place_id'), table_name='events') + op.drop_index(op.f('ix_events_person_id'), table_name='events') + op.drop_index(op.f('ix_events_event_type'), table_name='events') + op.drop_table('events') + op.drop_index(op.f('ix_relationships_tree_id'), table_name='relationships') + op.drop_index(op.f('ix_relationships_person_to_id'), table_name='relationships') + op.drop_index(op.f('ix_relationships_person_from_id'), table_name='relationships') + op.drop_table('relationships') + op.drop_index(op.f('ix_place_names_tree_id'), table_name='place_names') + op.drop_index(op.f('ix_place_names_place_id'), table_name='place_names') + op.drop_table('place_names') + op.drop_index(op.f('ix_names_tree_id'), table_name='names') + op.drop_index(op.f('ix_names_person_id'), table_name='names') + op.drop_table('names') + op.drop_index(op.f('ix_tree_memberships_user_id'), table_name='tree_memberships') + op.drop_index(op.f('ix_tree_memberships_tree_id'), table_name='tree_memberships') + op.drop_table('tree_memberships') + op.drop_index(op.f('ix_sources_tree_id'), table_name='sources') + op.drop_table('sources') + op.drop_index(op.f('ix_places_tree_id'), table_name='places') + op.drop_index(op.f('ix_places_parent_id'), table_name='places') + op.drop_table('places') + op.drop_index(op.f('ix_persons_tree_id'), table_name='persons') + op.drop_table('persons') + op.drop_index(op.f('ix_audit_entries_tree_id'), table_name='audit_entries') + op.drop_index(op.f('ix_audit_entries_created_at'), table_name='audit_entries') + op.drop_index(op.f('ix_audit_entries_actor_user_id'), table_name='audit_entries') + op.drop_table('audit_entries') + op.drop_index(op.f('ix_trees_owner_id'), table_name='trees') + op.drop_table('trees') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### + + # Enum types are created implicitly by create_table() but not dropped by + # drop_table(); drop them explicitly so downgrade is fully reversible. + for enum_name in ( + "tree_visibility", + "membership_role", + "person_privacy", + "relationship_type", + "parent_child_qualifier", + "citation_confidence", + "audit_actor_type", + ): + op.execute(f"DROP TYPE IF EXISTS {enum_name}") diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..7092026 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "provenance-backend" +version = "0.0.0" +description = "Provenance backend — FastAPI service for family + land provenance." +requires-python = ">=3.13" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.34", + "pydantic>=2.9", + "pydantic-settings>=2.5", + "sqlalchemy[asyncio]>=2.0", + "asyncpg>=0.30", + "alembic>=1.14", + "argon2-cffi>=23.1", +] + +[dependency-groups] +dev = [ + "ruff>=0.8", + "pytest>=8.3", + "pytest-asyncio>=0.24", + "httpx>=0.27", +] + +# This is an application, not a library: install dependencies but do not build/ +# install the project itself. Code runs from source via `uv run`. +[tool.uv] +package = false + +[tool.ruff] +line-length = 100 +target-version = "py313" +# Alembic writes the migration files; don't hold generated code to our style. +extend-exclude = ["migrations/versions"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = ["."] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..1682de0 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,89 @@ +"""Test fixtures. + +DB-backed tests require ``TEST_DATABASE_URL`` (an async URL to a *disposable* +Postgres); without it they skip, so the no-DB unit suite still runs anywhere. +The schema is built from the models via ``create_all`` and dropped per test for +isolation. A capturing mailer replaces the real one so email flows are testable. +""" + +import os + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +import app.models # noqa: F401 — register all models on Base.metadata +from app.api.deps import get_mailer +from app.core.db import get_session +from app.integrations.mailer.base import Mailer +from app.main import app +from app.models import Base + +TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") + + +class CapturingMailer(Mailer): + def __init__(self) -> None: + self.verifications: list[tuple[str, str]] = [] + self.resets: list[tuple[str, str]] = [] + + async def send_email_verification(self, *, to: str, link: str) -> None: + self.verifications.append((to, link)) + + async def send_password_reset(self, *, to: str, link: str) -> None: + self.resets.append((to, link)) + + +_mailer = CapturingMailer() + + +@pytest.fixture +def mailer() -> CapturingMailer: + return _mailer + + +@pytest_asyncio.fixture +async def client(): + if not TEST_DATABASE_URL: + pytest.skip("TEST_DATABASE_URL not set") + + engine = create_async_engine(TEST_DATABASE_URL) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + async def _override_session(): + async with sessionmaker() as session: + yield session + + _mailer.verifications.clear() + _mailer.resets.clear() + app.dependency_overrides[get_session] = _override_session + app.dependency_overrides[get_mailer] = lambda: _mailer + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + app.dependency_overrides.clear() + await engine.dispose() + + +def token_from_link(link: str) -> str: + return link.split("token=", 1)[1] + + +async def register(client, email: str, password: str = "password123") -> str: + """Register a user and return their bearer session token.""" + resp = await client.post( + "/api/v1/auth/register", json={"email": email, "password": password} + ) + assert resp.status_code == 201, resp.text + return resp.json()["token"] + + +def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..ee09cb3 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,106 @@ +"""Auth flows: registration, login, email verification, password reset, logout.""" + +from tests.conftest import auth, register, token_from_link + + +async def test_register_issues_session_and_verification_email(client, mailer): + resp = await client.post( + "/api/v1/auth/register", + json={"email": "new@example.com", "password": "password123", "display_name": "New"}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["token"] + assert body["user"]["email"] == "new@example.com" + assert body["user"]["email_verified_at"] is None + # A verification email was "sent". + assert len(mailer.verifications) == 1 + assert mailer.verifications[0][0] == "new@example.com" + + +async def test_duplicate_email_conflicts(client): + await register(client, "dupe@example.com") + resp = await client.post( + "/api/v1/auth/register", json={"email": "dupe@example.com", "password": "password123"} + ) + assert resp.status_code == 409 + + +async def test_login_wrong_password_rejected(client): + await register(client, "user@example.com", password="password123") + resp = await client.post( + "/api/v1/auth/login", json={"email": "user@example.com", "password": "wrong-password"} + ) + assert resp.status_code == 401 + + +async def test_login_succeeds_and_me_returns_user(client): + await register(client, "user2@example.com", password="password123") + resp = await client.post( + "/api/v1/auth/login", json={"email": "user2@example.com", "password": "password123"} + ) + assert resp.status_code == 200 + token = resp.json()["token"] + resp = await client.get("/api/v1/users/me", headers=auth(token)) + assert resp.status_code == 200 + assert resp.json()["email"] == "user2@example.com" + + +async def test_email_verification(client, mailer): + await register(client, "verify@example.com") + token = token_from_link(mailer.verifications[0][1]) + resp = await client.post("/api/v1/auth/verify-email", json={"token": token}) + assert resp.status_code == 204 + + # Logging in and checking /me shows the address is now verified. + login = await client.post( + "/api/v1/auth/login", json={"email": "verify@example.com", "password": "password123"} + ) + me = await client.get("/api/v1/users/me", headers=auth(login.json()["token"])) + assert me.json()["email_verified_at"] is not None + + +async def test_password_reset_flow_revokes_old_sessions(client, mailer): + old_token = await register(client, "reset@example.com", password="password123") + + resp = await client.post( + "/api/v1/auth/request-password-reset", json={"email": "reset@example.com"} + ) + assert resp.status_code == 202 + reset_token = token_from_link(mailer.resets[0][1]) + + resp = await client.post( + "/api/v1/auth/reset-password", + json={"token": reset_token, "new_password": "new-password456"}, + ) + assert resp.status_code == 204 + + # Old session is revoked. + assert (await client.get("/api/v1/users/me", headers=auth(old_token))).status_code == 401 + # Old password no longer works; new one does. + assert ( + await client.post( + "/api/v1/auth/login", json={"email": "reset@example.com", "password": "password123"} + ) + ).status_code == 401 + assert ( + await client.post( + "/api/v1/auth/login", + json={"email": "reset@example.com", "password": "new-password456"}, + ) + ).status_code == 200 + + +async def test_request_password_reset_unknown_email_still_accepted(client, mailer): + resp = await client.post( + "/api/v1/auth/request-password-reset", json={"email": "nobody@example.com"} + ) + assert resp.status_code == 202 + assert len(mailer.resets) == 0 # no email sent, but no enumeration either + + +async def test_logout_revokes_session(client): + token = await register(client, "logout@example.com") + assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200 + assert (await client.post("/api/v1/auth/logout", headers=auth(token))).status_code == 204 + assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401 diff --git a/backend/tests/test_core_api.py b/backend/tests/test_core_api.py new file mode 100644 index 0000000..c874feb --- /dev/null +++ b/backend/tests/test_core_api.py @@ -0,0 +1,73 @@ +"""End-to-end coverage of the core data model through the API: tenancy, the +privacy seam, and real session auth.""" + +from tests.conftest import auth, register + + +async def test_tree_and_person_flow(client): + token = await register(client, "keeper@example.com") + + resp = await client.post( + "/api/v1/trees", + json={"name": "Smith Family", "visibility": "private"}, + headers=auth(token), + ) + assert resp.status_code == 201, resp.text + tree = resp.json() + assert tree["visibility"] == "private" + tree_id = tree["id"] + + resp = await client.get("/api/v1/trees", headers=auth(token)) + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + resp = await client.post( + f"/api/v1/trees/{tree_id}/persons", + json={"given": "John", "surname": "Smith"}, + headers=auth(token), + ) + assert resp.status_code == 201, resp.text + person = resp.json() + assert person["primary_name"] == "John Smith" + assert person["tree_id"] == tree_id + + resp = await client.get(f"/api/v1/trees/{tree_id}/persons", headers=auth(token)) + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + +async def test_private_tree_isolated_from_other_users(client): + owner = await register(client, "owner@example.com") + other = await register(client, "stranger@example.com") + + resp = await client.post( + "/api/v1/trees", json={"name": "Private", "visibility": "private"}, headers=auth(owner) + ) + tree_id = resp.json()["id"] + + resp = await client.get(f"/api/v1/trees/{tree_id}", headers=auth(other)) + assert resp.status_code == 403 + resp = await client.get(f"/api/v1/trees/{tree_id}/persons", headers=auth(other)) + assert resp.status_code == 403 + + +async def test_public_tree_viewable_but_not_editable_by_non_member(client): + owner = await register(client, "owner2@example.com") + viewer = await register(client, "viewer2@example.com") + + resp = await client.post( + "/api/v1/trees", json={"name": "Public", "visibility": "public"}, headers=auth(owner) + ) + tree_id = resp.json()["id"] + + resp = await client.get(f"/api/v1/trees/{tree_id}", headers=auth(viewer)) + assert resp.status_code == 200 + resp = await client.post( + f"/api/v1/trees/{tree_id}/persons", json={"given": "Nope"}, headers=auth(viewer) + ) + assert resp.status_code == 403 + + +async def test_auth_required_without_token(client): + resp = await client.get("/api/v1/trees") + assert resp.status_code == 401 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..45fb046 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,17 @@ +"""Liveness probe test. Readiness is covered by an integration test once a +test database fixture exists (it requires a live Postgres).""" + +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health_liveness(): + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["service"] == "Provenance" + assert "version" in body diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..9c5d5de --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,914 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "provenance-backend" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "alembic" }, + { name = "argon2-cffi" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.14" }, + { name = "argon2-cffi", specifier = ">=23.1" }, + { name = "asyncpg", specifier = ">=0.30" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "pydantic", specifier = ">=2.9" }, + { name = "pydantic-settings", specifier = ">=2.5" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, + { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "starlette" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..87f2a5e --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,59 @@ +# Provenance configuration — copy to `.env` and fill in. Never commit `.env`. +# Everything is twelve-factor; no endpoints or secrets live in code. + +# --- Core --- +APP_ENV=development + +# --- Images (pulled from git.jpaul.io; CI pushes to the LAN registry) --- +# test-main = current main build; or pin a semver / test-sha- for rollback. +IMAGE_TAG=test-main + +# --- Database (Postgres) --- +POSTGRES_USER=provenance +POSTGRES_PASSWORD=change-me +POSTGRES_DB=provenance +# Backend connection string (async driver). Host 'postgres' = compose service. +DATABASE_URL=postgresql+asyncpg://provenance:change-me@postgres:5432/provenance + +# --- Object storage (S3-compatible / MinIO) --- +MINIO_ROOT_USER=provenance +MINIO_ROOT_PASSWORD=change-me-too +S3_ENDPOINT_URL=http://minio:9000 +S3_BUCKET=provenance +S3_ACCESS_KEY=provenance +S3_SECRET_KEY=change-me-too +S3_REGION=us-east-1 + +# --- Edge (Caddy) --- +# Local: ':80' (http://localhost). Production: 'provenance.example.com' for auto-HTTPS. +# Behind a Cloudflare Tunnel, keep ':80' — Cloudflare terminates TLS and the +# tunnel forwards plain HTTP to caddy:80. +PROVENANCE_SITE_ADDRESS=:80 + +# --- Cloudflare Tunnel (optional) --- +# Enable by setting COMPOSE_PROFILES=tunnel and supplying the connector token +# from the Cloudflare dashboard. Public hostname -> http://caddy:80. +CLOUDFLARE_TUNNEL_TOKEN= +COMPOSE_PROFILES= + +# --- Auth / sessions --- +SESSION_TTL_DAYS=30 +TOKEN_TTL_HOURS=24 +# Set false for local http; true (default) behind TLS. +COOKIE_SECURE=false +# Base URL used to build links in outbound email. +APP_BASE_URL=http://localhost +# Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below. +MAILER=console + +# --- Email (SMTP) — wired in a later phase --- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM= + +# --- Model providers — wired in Phase 4 (AI assistant). BYO key. --- +# ANTHROPIC_API_KEY= +# OPENAI_API_KEY= +# XAI_API_KEY= diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..8cfb60d --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,22 @@ +# Provenance edge. Site address is env-driven: ':80' for local http://localhost, +# a domain in production for automatic HTTPS. Behind a Cloudflare Tunnel you can +# keep this on plain HTTP and let the tunnel terminate TLS. + +{$PROVENANCE_SITE_ADDRESS::80} { + encode gzip + + # Versioned API surface (FastAPI). The assistant mounts under /assistant later. + handle /api/* { + reverse_proxy backend:8000 + } + + # Liveness/readiness probes, proxied for external monitoring. + handle /health* { + reverse_proxy backend:8000 + } + + # Everything else -> the Next.js frontend. + handle { + reverse_proxy frontend:3000 + } +} diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml new file mode 100644 index 0000000..c459e4e --- /dev/null +++ b/deploy/docker-compose.dev.yml @@ -0,0 +1,18 @@ +name: provenance + +# Dev override: build the backend/frontend images locally instead of pulling +# them from the registry. Layer it on top of the base compose: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build +# +# Use this before CI has published images, or to test local changes. + +services: + backend: + build: + context: ../backend + dockerfile: Dockerfile + + frontend: + build: + context: ../frontend + dockerfile: Dockerfile diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..d87e925 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,111 @@ +name: provenance + +# One stack stands up the whole system. Configuration is entirely env-driven +# (see .env.example). Run from this directory: `docker compose up -d`. +# +# backend/frontend are PULLED from the public registry (git.jpaul.io); CI pushes +# them to the LAN endpoint (192.168.0.2:1234). For local building instead of +# pulling, layer the dev override: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build + +services: + postgres: + # pgvector image = Postgres + pgvector; pg_trgm ships in contrib. + image: pgvector/pgvector:pg17 + environment: + POSTGRES_USER: ${POSTGRES_USER:-provenance} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-provenance} + POSTGRES_DB: ${POSTGRES_DB:-provenance} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-provenance} -d ${POSTGRES_DB:-provenance}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-provenance} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me-too} + volumes: + - miniodata:/data + healthcheck: + test: ["CMD-SHELL", "mc ready local || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + backend: + image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main} + environment: + APP_ENV: ${APP_ENV:-development} + DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance} + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: + - CMD-SHELL + - >- + python -c "import urllib.request,sys; + sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)" + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + + frontend: + image: git.jpaul.io/justin/provenance-frontend:${IMAGE_TAG:-test-main} + environment: + NODE_ENV: production + depends_on: + - backend + restart: unless-stopped + + caddy: + image: caddy:2 + ports: + - "80:80" + - "443:443" + environment: + # Local default ':80' -> http://localhost. Set to a domain in production + # for automatic HTTPS (or run plain HTTP behind a Cloudflare Tunnel). + PROVENANCE_SITE_ADDRESS: ${PROVENANCE_SITE_ADDRESS:-:80} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddydata:/data + - caddyconfig:/config + depends_on: + - backend + - frontend + restart: unless-stopped + + # Cloudflare Tunnel connector. The tunnel/ingress is configured in the + # Cloudflare dashboard; this container just connects. One public hostname + # (e.g. provenance.paul.farm) -> http://caddy:80 is enough, because Caddy + # does the internal path routing (/ -> frontend, /api + /health -> backend). + # + # Opt-in via the "tunnel" profile so local dev doesn't start it. On the lab + # host set COMPOSE_PROFILES=tunnel so `docker compose up -d` includes it. + cloudflared: + image: cloudflare/cloudflared:latest + restart: unless-stopped + command: tunnel --no-autoupdate run + environment: + TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-} + depends_on: + - caddy + profiles: + - tunnel + +volumes: + pgdata: + miniodata: + caddydata: + caddyconfig: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6fd7e9a..81ee560 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -79,6 +79,7 @@ Async throughout (FastAPI + async SQLAlchemy). Anything that can be slow or can - **Authoring UI** is client-side, talking to the REST API. A generated TypeScript client keeps it in sync with the OpenAPI contract. - **Mobile-first.** Layouts are responsive and touch-first; no separate mobile codebase. Feature parity with desktop is a requirement, not a nice-to-have. - **Design system:** Tailwind + shadcn/ui for a consistent, accessible (WCAG 2.2 AA target) component layer. +- *Status:* the scaffold has landed — Next.js (App Router) + Tailwind + shadcn-style primitives, a typed client generated from the backend OpenAPI spec (`openapi-typescript` + `openapi-fetch`), and auth + tree/person views. Auth rides the same-origin HttpOnly session cookie (Caddy proxies `/api/*` to the backend). Built as a standalone container; Caddy routes `/` to it. ## 5. Data model @@ -93,7 +94,7 @@ Core entities and the important relationships. (Illustrative, not final DDL.) - **Person** — belongs to a Tree. Has many **Name** records (with parts: given, surname, prefix/suffix, and a type such as birth/married/alias) to support variants and changes over time. Carries living/deceased status. - **Relationship** — typed edge between two Persons within a Tree: parent–child (with a qualifier: biological, adoptive, step, foster, donor, guardian), partnership/marriage (with its own events), sibling (often derived). Modeling parentage as qualified edges — rather than assuming a two-biological-parent nuclear family — is what makes adoption, donor conception, and blended families first-class rather than awkward. - **Event** — typed (birth, death, marriage, residence, immigration, etc.), with a date (supporting ranges, approximations, and non-Gregorian calendars), an optional Place, and attachable to a Person or a partnership. -- **Place** — a tenant-shared gazetteer entity: hierarchical (place within place), with **historical name variants and date ranges** so a record entered as "Königsberg, 1900" sorts and displays correctly against "Kaliningrad." Optional coordinates. +- **Place** — a tenant-shared gazetteer entity: hierarchical (place within place), with **historical name variants and date ranges** so a record entered as "Königsberg, 1900" sorts and displays correctly against "Kaliningrad." Optional coordinates. *(Phase 0 scopes Place to a Tree via `tree_id` for absolute tenant isolation; a deployment-wide shared gazetteer is a deliberate later refinement. Variants live in a `PlaceName` child table.)* ### Sources (first-class) - **Source** — a reusable record of an origin: title, repository, type, optional URL, free citation text, optional quality grade. One Source backs many facts. @@ -147,6 +148,7 @@ Three parts, deliberately separated: - `AuthProvider` interface with implementations for **local** (password + email verification/reset), **OIDC** (validated against Authentik; expected to work with Keycloak, Auth0, etc.), and **social** (Google, Apple, Facebook). - Operators enable any subset via config. This deployment will use Authentik (`auth.jpaul.io`) plus selected social providers; a bare self-hoster can run local-only. - Sessions are backend-issued; the assistant principal is minted per-session and scoped to the acting user. +- *Status:* **local auth has landed** — Argon2id password hashing, opaque backend-issued sessions (only the token hash is stored; presented as a Bearer token or HttpOnly cookie), and email verification + password reset via the `Mailer` interface (console in dev, SMTP for operators). OIDC and social providers are Phase 5. Every write records an attributable actor in the audit log. ## 10. Search @@ -168,13 +170,26 @@ Jobs are idempotent and retryable; an external failure degrades gracefully rathe ## 12. Deployment & CI/CD -- **Images** are built by **Gitea Actions** on `git.jpaul.io` and pushed to the **Gitea container registry**. -- Servers **pull** new images to deploy — no build on the host. +- **Images** are built by **Gitea Actions** (`runs-on: docker`) and pushed to the **Gitea container registry**, one package per component (`provenance-backend`, `provenance-frontend`) linked to the repo. +- **Split push/pull endpoints** (mirrors the drawbar setup): CI **pushes** to the LAN registry endpoint `192.168.0.2:1234` over plain HTTP (buildx configured `insecure`/`http`) to bypass the Cloudflare request-body limit; servers **pull** from the public `git.jpaul.io` FQDN (TLS via Cloudflare). Same Gitea registry, two front doors. Auth uses the `REGISTRY_TOKEN` Actions secret. +- Tag scheme: `test-main` (current main), `test-sha-` (rollback pins), the component version, and `latest` on `v*` tags. +- Servers **pull** new images to deploy — no build on the host. The deploy compose references `git.jpaul.io/justin/provenance-{backend,frontend}:${IMAGE_TAG:-test-main}`; `docker-compose.dev.yml` is a local-build override. - **Caddy** terminates TLS and reverse-proxies frontend + backend. **Cloudflare Tunnel** is the preferred ingress (no open inbound ports) but is never required; a plain Caddy-on-a-public-host deployment is equally supported. - **Configuration** is entirely environment-driven (twelve-factor). One `.env` plus the compose file is enough to stand up a deployment. - **Migrations** run on backend start (or via an explicit job) so an image pull + restart is a complete upgrade. - **Backups:** documented procedure for Postgres dump + object-store sync; restore is the inverse. +**Repository layout (as scaffolded):** + +``` +/backend # FastAPI, uv-managed. app/{api/v1, services (+privacy), repositories, models, schemas, integrations (auth/mailer), core}; migrations/ = Alembic +/deploy # docker-compose.yml, Caddyfile, .env.example +/.gitea/workflows # Gitea Actions: build images → Gitea registry +/frontend # Next.js (App Router, TS, Tailwind). app/ pages, lib/api (openapi-typescript client), components/ui, Dockerfile (standalone) +``` + +The compose stack runs `postgres` (pgvector image — includes `pgvector`; `pg_trgm` ships in contrib), `minio`, `backend`, and `caddy`. The **worker** container (same image as backend, worker mode) joins once queue-driven jobs exist. Phase 0 ships a minimal backend with `/health` (liveness) and `/health/ready` (Postgres reachability) to validate the deploy wiring before the data model lands. + ## 13. Observability - Structured (JSON) logs from backend and worker. diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..463b6d8 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +out +build +npm-debug.log* +.env*.local +README.md diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..06c369c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/.next +/out +/build +next-env.d.ts +.env*.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c2fb58c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +# Next standalone output: a minimal server with only the traced dependencies. +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..cc6da71 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,19 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #0a0a0a; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..a49acdb --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import "./globals.css"; + +export const metadata: Metadata = { + title: "Provenance", + description: "Where it came from matters — family and land, every fact sourced.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +
+
+ + Provenance + + +
+
+
{children}
+ + + ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..f6e3ec1 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { api } from "@/lib/api/client"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + const { error } = await api.POST("/api/v1/auth/login", { body: { email, password } }); + setLoading(false); + if (error) { + setError("Invalid email or password."); + return; + } + router.push("/trees"); + } + + return ( + + + Sign in + + +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + +
+

+ No account?{" "} + + Create one + +

+
+
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..cf2db05 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; + +export default function Home() { + return ( +
+
+

Provenance

+

+ Trace where you come from — your family and your land — with every fact linked to a + source, on infrastructure you control. +

+
+
+ + + + + + +
+
+ ); +} diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 0000000..a580d1d --- /dev/null +++ b/frontend/app/register/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { api } from "@/lib/api/client"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function RegisterPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + const { error } = await api.POST("/api/v1/auth/register", { + body: { email, password, display_name: displayName || null }, + }); + setLoading(false); + if (error) { + setError("Could not register. The email may already be in use, or the password is too short (min 8)."); + return; + } + router.push("/trees"); + } + + return ( + + + Create your account + + +
+
+ + setDisplayName(e.target.value)} /> +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + minLength={8} + required + /> +
+ {error &&

{error}

} + +
+

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/frontend/app/trees/[id]/page.tsx b/frontend/app/trees/[id]/page.tsx new file mode 100644 index 0000000..7723848 --- /dev/null +++ b/frontend/app/trees/[id]/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { api } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +type Person = components["schemas"]["PersonRead"]; + +export default function TreeDetailPage() { + const router = useRouter(); + const params = useParams<{ id: string }>(); + const treeId = params.id; + + const [persons, setPersons] = useState([]); + const [given, setGiven] = useState(""); + const [surname, setSurname] = useState(""); + const [ready, setReady] = useState(false); + + const load = useCallback(async () => { + const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", { + params: { path: { tree_id: treeId } }, + }); + if (response.status === 401) { + router.push("/login"); + return; + } + setPersons(data ?? []); + setReady(true); + }, [router, treeId]); + + useEffect(() => { + load(); + }, [load]); + + async function addPerson(e: React.FormEvent) { + e.preventDefault(); + if (!given.trim() && !surname.trim()) return; + const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", { + params: { path: { tree_id: treeId } }, + body: { given: given || null, surname: surname || null }, + }); + if (!error) { + setGiven(""); + setSurname(""); + load(); + } + } + + if (!ready) return

Loading…

; + + return ( +
+ + ← All trees + + + + + Add a person + + +
+ setGiven(e.target.value)} /> + setSurname(e.target.value)} /> + +
+
+
+ +
+

People

+ {persons.length === 0 ? ( +

No people yet.

+ ) : ( +
    + {persons.map((person) => ( +
  • + + + {person.primary_name ?? Unnamed} + + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/trees/page.tsx b/frontend/app/trees/page.tsx new file mode 100644 index 0000000..ef22baa --- /dev/null +++ b/frontend/app/trees/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { api } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +type Tree = components["schemas"]["TreeRead"]; + +export default function TreesPage() { + const router = useRouter(); + const [trees, setTrees] = useState([]); + const [name, setName] = useState(""); + const [ready, setReady] = useState(false); + + const load = useCallback(async () => { + const { data, response } = await api.GET("/api/v1/trees"); + if (response.status === 401) { + router.push("/login"); + return; + } + setTrees(data ?? []); + setReady(true); + }, [router]); + + useEffect(() => { + load(); + }, [load]); + + async function createTree(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim()) return; + const { error } = await api.POST("/api/v1/trees", { body: { name } }); + if (!error) { + setName(""); + load(); + } + } + + async function logout() { + await api.POST("/api/v1/auth/logout"); + router.push("/login"); + } + + if (!ready) return

Loading…

; + + return ( +
+
+

Your trees

+ +
+ + + + New tree + + +
+ setName(e.target.value)} + /> + +
+
+
+ + {trees.length === 0 ? ( +

No trees yet — create your first one above.

+ ) : ( +
    + {trees.map((tree) => ( +
  • + + + + {tree.name} + + {tree.visibility} + + + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..d00fdb9 --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-neutral-900 text-white hover:bg-neutral-700", + outline: "border border-neutral-300 bg-transparent hover:bg-neutral-100", + ghost: "hover:bg-neutral-100", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 px-3", + }, + }, + defaultVariants: { variant: "default", size: "default" }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +export const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( +