From 03aa9a3ca730404774b66da3f3a34e1a1cdbde26 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:16:58 -0400 Subject: [PATCH 01/17] Scaffold FastAPI backend skeleton with health probes Phase 0 foundation. uv-managed FastAPI app (package=false, runs from source via uv run). Layered seams in place: app/api for routers, app/core for config (pydantic-settings, fully env-driven) and the async SQLAlchemy engine; service/repository/domain layers land with the data model. Exposes /health (liveness) and /health/ready (Postgres reachability via SELECT 1, 503 on failure) so the deploy wiring is verifiable before any data model exists. Includes a liveness test and the resolved uv.lock. Ignore pytest/ruff/mypy caches. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- .gitignore | 5 + backend/.dockerignore | 10 + backend/Dockerfile | 25 ++ backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/health.py | 41 ++ backend/app/core/__init__.py | 0 backend/app/core/config.py | 33 ++ backend/app/core/db.py | 22 + backend/app/main.py | 25 ++ backend/pyproject.toml | 38 ++ backend/tests/test_health.py | 17 + backend/uv.lock | 811 +++++++++++++++++++++++++++++++++++ 13 files changed, 1027 insertions(+) create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/health.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/db.py create mode 100644 backend/app/main.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/test_health.py create mode 100644 backend/uv.lock 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/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..c7f3954 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,25 @@ +# 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 (project is package=false, so no install step needed). +COPY app ./app + +EXPOSE 8000 + +CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] 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/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/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..cbec505 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,33 @@ +"""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", + ) + + +@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..98f5658 --- /dev/null +++ b/backend/app/core/db.py @@ -0,0 +1,22 @@ +"""Async database engine. + +A single lazily-created async engine for the process. The repository layer +(coming with the data model) will build sessions on top of this; for now it +backs the readiness probe. +""" + +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from app.core.config import get_settings + +_engine: AsyncEngine | 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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..3af2627 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,25 @@ +"""FastAPI application entrypoint. + +Thin by design: wire settings and routers, expose the OpenAPI contract. All +domain logic lives in the service layer (added with the data model). The +versioned API will mount under ``/api/v1``; health probes stay at the root. +""" + +from fastapi import FastAPI + +from app.api.health import router as health_router +from app.core.config import get_settings + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( + title=settings.app_name, + version=settings.version, + description="Provenance API — family and land provenance.", + ) + app.include_router(health_router) + return app + + +app = create_app() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..fa7b563 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,38 @@ +[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", +] + +[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" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = ["."] 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..4c5dc4c --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,811 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[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 = "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 = "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 = "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 = "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 = "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" }, +] From 0b5c3b260aa0fd8e6344d8719bac2e8590f966d9 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:17:12 -0400 Subject: [PATCH 02/17] Add self-host compose stack (Postgres, MinIO, backend, Caddy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One env-driven compose stack stands up the whole system per ARCHITECTURE §2/§12. Postgres uses the pgvector image (pgvector + pg_trgm in contrib); MinIO is the S3-compatible store; Caddy reverse-proxies /api/* and /health* to the backend with an env-driven site address (':80' local, a domain for auto-HTTPS, or plain HTTP behind a Cloudflare Tunnel). Healthchecks and depends_on gate startup order. .env.example documents twelve-factor config (DB, S3, SMTP, Caddy, model keys) with placeholders; no secrets in the repo. Verified end-to-end on the deploy target: all services healthy, /health/ready green against real Postgres. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- deploy/.env.example | 37 ++++++++++++++++++ deploy/Caddyfile | 25 ++++++++++++ deploy/docker-compose.yml | 81 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 deploy/.env.example create mode 100644 deploy/Caddyfile create mode 100644 deploy/docker-compose.yml diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..923efaa --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,37 @@ +# 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 + +# --- 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. +PROVENANCE_SITE_ADDRESS=:80 + +# --- 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..c784da2 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,25 @@ +# 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 + } + + # Frontend (Next.js) — not yet deployed in Phase 0. Uncomment when it lands. + # handle { + # reverse_proxy frontend:3000 + # } + handle { + respond "Provenance — Phase 0. Backend health at /health; frontend not yet deployed." 200 + } +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..75d5d33 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,81 @@ +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`. + +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: + build: + context: ../backend + dockerfile: Dockerfile + 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 + + 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 + restart: unless-stopped + +volumes: + pgdata: + miniodata: + caddydata: + caddyconfig: From 9e4252ba8f0a07057cd574204136a285a6e4a3da Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:17:12 -0400 Subject: [PATCH 03/17] Add Gitea Actions CI to build the backend image Builds and pushes the backend container image to the Gitea registry on git.jpaul.io on push to main and version tags, so servers pull to deploy (no build on the host). Registry credentials come from repo secrets (REGISTRY_USERNAME/REGISTRY_PASSWORD); runner label may need adjusting to the configured Gitea runner. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- .gitea/workflows/build.yml | 40 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .gitea/workflows/build.yml diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..cae4325 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,40 @@ +name: build-images + +# Gitea Actions build container images and push to the Gitea registry on +# git.jpaul.io. Servers pull to deploy — no build on the host. +# +# Requires repo/org secrets: REGISTRY_USERNAME, REGISTRY_PASSWORD (a token with +# package:write). Adjust the runner label to one your Gitea runner advertises. + +on: + push: + branches: [main] + tags: ["v*"] + +env: + REGISTRY: git.jpaul.io + IMAGE_BASE: git.jpaul.io/${{ github.repository }} + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to the Gitea registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push backend image + uses: docker/build-push-action@v6 + with: + context: ./backend + push: true + tags: | + ${{ env.IMAGE_BASE }}/backend:latest + ${{ env.IMAGE_BASE }}/backend:${{ github.sha }} From 03124027fe08094dc7866d7e6971f7948618c913 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:17:12 -0400 Subject: [PATCH 04/17] Record Phase 0 repo layout in CLAUDE.md and ARCHITECTURE Documents the scaffolded tree (/backend, /deploy, /.gitea, pending /frontend), the deploy-first sequencing, and the toolchain choices (uv for backend deps, Alembic for migrations), as CLAUDE.md's layout section requires when code lands. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- CLAUDE.md | 6 +++++- docs/ARCHITECTURE.md | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3e07527..ba17a40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,9 +38,13 @@ 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 / core (config, db); more layers land with the data model +/deploy # docker-compose.yml, Caddyfile, .env.example — the self-host stack +/.gitea/workflows # Gitea Actions CI (build images → Gitea registry) +/frontend # Next.js app — not yet scaffolded (Phase 0, after the deploy story) ``` -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 will use **Alembic**. Keep this section current as the tree grows. ## Where to start diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6fd7e9a..fddee91 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -175,6 +175,17 @@ Jobs are idempotent and retryable; an external failure degrades gracefully rathe - **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 service, uv-managed (app/ = api, core; service/repository/domain land with the data model) +/deploy # docker-compose.yml, Caddyfile, .env.example +/.gitea/workflows # Gitea Actions: build images → Gitea registry +/frontend # Next.js (pending) +``` + +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. From 297cb797d6f3267fa970f19a0dee30eb687ea795 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:40:00 -0400 Subject: [PATCH 05/17] Add core data model (12 tables) and initial Alembic migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All core entities from ARCHITECTURE §5: tenancy (User, Tree, TreeMembership), people (Person, Name, Relationship), facts (Event, Place, PlaceName), provenance (Source, Citation), and the append-only AuditEntry. Cross-cutting mixins give every row a UUID key, timestamps, soft delete, and (where tree-owned) a tree_id for uniform tenant isolation. Modeling choices: parentage as qualified edges (biological/adoptive/step/foster/donor/guardian) so non-traditional families are first-class; events keep both a verbatim date string and a normalized start/end range; closed sets are PG enums while GEDCOM-extensible vocabularies (event/name/source type) stay strings; CHECK constraints enforce single-subject events and single-target citations. Place is tree-scoped in Phase 0 (see ARCHITECTURE note). The migration is verified reversible (upgrade/downgrade drops tables and enum types) and matches the models (alembic check clean); applied on the deploy target. Dockerfile now ships migrations. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/Dockerfile | 4 +- backend/alembic.ini | 41 +++ backend/app/models/__init__.py | 28 ++ backend/app/models/audit.py | 42 +++ backend/app/models/base.py | 20 ++ backend/app/models/enums.py | 57 ++++ backend/app/models/event.py | 51 +++ backend/app/models/mixins.py | 45 +++ backend/app/models/person.py | 52 +++ backend/app/models/place.py | 42 +++ backend/app/models/relationship.py | 40 +++ backend/app/models/source.py | 66 ++++ backend/app/models/tree.py | 43 +++ backend/app/models/user.py | 21 ++ backend/migrations/env.py | 59 ++++ backend/migrations/script.py.mako | 26 ++ .../versions/ec43c338e155_core_data_model.py | 304 ++++++++++++++++++ backend/pyproject.toml | 2 + 18 files changed, 942 insertions(+), 1 deletion(-) create mode 100644 backend/alembic.ini create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/audit.py create mode 100644 backend/app/models/base.py create mode 100644 backend/app/models/enums.py create mode 100644 backend/app/models/event.py create mode 100644 backend/app/models/mixins.py create mode 100644 backend/app/models/person.py create mode 100644 backend/app/models/place.py create mode 100644 backend/app/models/relationship.py create mode 100644 backend/app/models/source.py create mode 100644 backend/app/models/tree.py create mode 100644 backend/app/models/user.py create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/ec43c338e155_core_data_model.py diff --git a/backend/Dockerfile b/backend/Dockerfile index c7f3954..0340f0f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,8 +17,10 @@ COPY pyproject.toml uv.lock* ./ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --no-dev -# Application source (project is package=false, so no install step needed). +# Application source + migrations (project is package=false, no install step). COPY app ./app +COPY alembic.ini ./alembic.ini +COPY migrations ./migrations EXPOSE 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/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e9b45c6 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,28 @@ +"""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.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", +] 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/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..933789c --- /dev/null +++ b/backend/app/models/enums.py @@ -0,0 +1,57 @@ +"""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" 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/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/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 index fa7b563..fdc41f1 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,6 +29,8 @@ 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"] From dffd05d3036222f6f2b5589c5d3b384e8020dd90 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:40:19 -0400 Subject: [PATCH 06/17] Add layered service/API for tenancy and people with the privacy seam Wires the data model through repository -> service -> API/v1. The privacy engine (app/services/privacy.py) is the single enforcement point: every read resolves visibility there (tree role, tree visibility, per-person override; living-person redaction is a marked Phase 2 TODO). All writes record an attributable AuditEntry. Endpoints: POST /users (open dev bootstrap until auth), GET /users/me, POST/GET /trees, GET /trees/{id}, and POST/GET /trees/{id}/persons. Authn is a temporary X-User-Id header shim; authz is membership-based (owner/editor/viewer). Domain errors map to 401/403/404/409. Verified on the deploy target: private tree -> 403 for non-members, missing actor -> 401, audit log populated. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/api/deps.py | 33 ++++++++ backend/app/api/v1/__init__.py | 10 +++ backend/app/api/v1/persons.py | 43 ++++++++++ backend/app/api/v1/trees.py | 33 ++++++++ backend/app/api/v1/users.py | 21 +++++ backend/app/core/db.py | 39 +++++++-- backend/app/main.py | 27 +++++- backend/app/repositories/__init__.py | 0 backend/app/repositories/base.py | 40 +++++++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/person.py | 27 ++++++ backend/app/schemas/tree.py | 23 +++++ backend/app/schemas/user.py | 22 +++++ backend/app/services/__init__.py | 0 backend/app/services/audit.py | 37 ++++++++ backend/app/services/exceptions.py | 18 ++++ backend/app/services/person_service.py | 113 +++++++++++++++++++++++++ backend/app/services/privacy.py | 62 ++++++++++++++ backend/app/services/tree_service.py | 61 +++++++++++++ backend/app/services/user_service.py | 44 ++++++++++ 20 files changed, 640 insertions(+), 13 deletions(-) create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/persons.py create mode 100644 backend/app/api/v1/trees.py create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/base.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/person.py create mode 100644 backend/app/schemas/tree.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/audit.py create mode 100644 backend/app/services/exceptions.py create mode 100644 backend/app/services/person_service.py create mode 100644 backend/app/services/privacy.py create mode 100644 backend/app/services/tree_service.py create mode 100644 backend/app/services/user_service.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..c9efb4f --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,33 @@ +"""Shared API dependencies.""" + +import uuid +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_session +from app.models.user import User +from app.services.user_service import get_user + +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +async def get_current_user( + session: SessionDep, + x_user_id: Annotated[uuid.UUID | None, Header()] = None, +) -> User: + """TEMPORARY pre-auth shim: identifies the caller via the ``X-User-Id`` + header. Replaced by the AuthProvider (sessions/tokens) in the auth slice. + The assistant principal will also be minted here, scoped to its user.""" + if x_user_id is None: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, "X-User-Id header required (pre-auth)" + ) + user = await get_user(session, x_user_id) + if user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unknown user") + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..7e4a596 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,10 @@ +"""Versioned API surface. Mounts under /api/v1.""" + +from fastapi import APIRouter + +from app.api.v1 import persons, trees, users + +api_router = APIRouter(prefix="/api/v1") +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/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..6b12887 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, status + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.user import UserCreate, UserRead +from app.services import user_service + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED) +async def create_user(data: UserCreate, session: SessionDep) -> UserRead: + # Open dev bootstrap until the auth slice; lets us create tree owners. + user = await user_service.create_user( + session, email=data.email, display_name=data.display_name + ) + return UserRead.model_validate(user) + + +@router.get("/me", response_model=UserRead) +async def read_me(current: CurrentUser) -> UserRead: + return UserRead.model_validate(current) diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 98f5658..a11feb0 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,22 +1,43 @@ -"""Async database engine. +"""Async database engine, session factory, and the FastAPI session dependency. -A single lazily-created async engine for the process. The repository layer -(coming with the data model) will build sessions on top of this; for now it -backs the readiness probe. +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 sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +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, - ) + _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/main.py b/backend/app/main.py index 3af2627..2f3dfd6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,14 +1,31 @@ """FastAPI application entrypoint. -Thin by design: wire settings and routers, expose the OpenAPI contract. All -domain logic lives in the service layer (added with the data model). The -versioned API will mount under ``/api/v1``; health probes stay at the root. +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. """ -from fastapi import FastAPI +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 _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: @@ -19,6 +36,8 @@ def create_app() -> FastAPI: description="Provenance API — family and land provenance.", ) app.include_router(health_router) + app.include_router(api_router) + _register_error_handlers(app) return app 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/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/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) From 64388b75bfdaef2b2f254b768443a498375279f1 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:40:19 -0400 Subject: [PATCH 07/17] Add core API integration tests End-to-end coverage of the tenancy/people flow and the privacy seam (private-tree isolation, public-tree view-but-not-edit, duplicate-email conflict, auth-required). DB-backed tests run against TEST_DATABASE_URL and skip cleanly when it is unset, so the no-DB suite still runs anywhere. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/tests/conftest.py | 47 ++++++++++++++++++ backend/tests/test_core_api.py | 91 ++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_core_api.py diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..9abe1b8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,47 @@ +"""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. +""" + +import os + +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.core.db import get_session +from app.main import app +from app.models import Base + +TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") + + +@pytest_asyncio.fixture +async def client(): + if not TEST_DATABASE_URL: + import pytest + + 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 + + app.dependency_overrides[get_session] = _override_session + 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() diff --git a/backend/tests/test_core_api.py b/backend/tests/test_core_api.py new file mode 100644 index 0000000..5da62cb --- /dev/null +++ b/backend/tests/test_core_api.py @@ -0,0 +1,91 @@ +"""End-to-end coverage of the core data model through the API: tenancy, the +privacy seam, and the pre-auth actor shim.""" + + +async def _create_user(client, email: str) -> str: + resp = await client.post( + "/api/v1/users", json={"email": email, "display_name": email} + ) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +async def test_tree_and_person_flow(client): + user_id = await _create_user(client, "keeper@example.com") + headers = {"X-User-Id": user_id} + + resp = await client.post( + "/api/v1/trees", json={"name": "Smith Family", "visibility": "private"}, headers=headers + ) + assert resp.status_code == 201, resp.text + tree = resp.json() + assert tree["visibility"] == "private" + assert tree["owner_id"] == user_id + tree_id = tree["id"] + + resp = await client.get("/api/v1/trees", headers=headers) + 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=headers, + ) + 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=headers) + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + +async def test_private_tree_isolated_from_other_users(client): + owner = await _create_user(client, "owner@example.com") + other = await _create_user(client, "stranger@example.com") + + resp = await client.post( + "/api/v1/trees", json={"name": "Private", "visibility": "private"}, + headers={"X-User-Id": owner}, + ) + tree_id = resp.json()["id"] + + # A non-member cannot view a private tree, nor list its people. + resp = await client.get(f"/api/v1/trees/{tree_id}", headers={"X-User-Id": other}) + assert resp.status_code == 403 + resp = await client.get(f"/api/v1/trees/{tree_id}/persons", headers={"X-User-Id": other}) + assert resp.status_code == 403 + + +async def test_public_tree_viewable_but_not_editable_by_non_member(client): + owner = await _create_user(client, "owner2@example.com") + viewer = await _create_user(client, "viewer2@example.com") + + resp = await client.post( + "/api/v1/trees", json={"name": "Public", "visibility": "public"}, + headers={"X-User-Id": owner}, + ) + tree_id = resp.json()["id"] + + # Visible to a non-member... + resp = await client.get(f"/api/v1/trees/{tree_id}", headers={"X-User-Id": viewer}) + assert resp.status_code == 200 + # ...but not writable (not an editor). + resp = await client.post( + f"/api/v1/trees/{tree_id}/persons", json={"given": "Nope"}, + headers={"X-User-Id": viewer}, + ) + assert resp.status_code == 403 + + +async def test_duplicate_email_conflicts(client): + await _create_user(client, "dupe@example.com") + resp = await client.post("/api/v1/users", json={"email": "dupe@example.com"}) + assert resp.status_code == 409 + + +async def test_auth_required_without_header(client): + resp = await client.get("/api/v1/trees") + assert resp.status_code == 401 From e5a871329317b99ea679b02fabdeb24e08fe4acc Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:40:19 -0400 Subject: [PATCH 08/17] Document core-model decisions in CLAUDE.md and ARCHITECTURE Records the landed data model and backend layout, the Phase 0 tree-scoping of Place (vs. the eventual shared gazetteer), and the temporary X-User-Id auth shim. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- CLAUDE.md | 4 ++-- docs/ARCHITECTURE.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ba17a40..3f1af02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,13 +38,13 @@ 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 / core (config, db); more layers land with the data model +/backend # FastAPI service (uv-managed). app/{api/v1, services (+ privacy engine), repositories, models, schemas, 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 — not yet scaffolded (Phase 0, after the deploy story) ``` -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 will use **Alembic**. 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) and its initial migration have landed; local auth and the frontend are next. A temporary `X-User-Id` header shim stands in for auth until that slice. Keep this section current as the tree grows. ## Where to start diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fddee91..b6b3bc6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -93,7 +93,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 +147,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. +- *Phase 0 interim:* until the auth slice lands, the API identifies the caller via a temporary `X-User-Id` header shim (replaced by real sessions/tokens), and account creation is an open dev bootstrap. Every write still records an attributable actor in the audit log. ## 10. Search @@ -178,7 +179,7 @@ Jobs are idempotent and retryable; an external failure degrades gracefully rathe **Repository layout (as scaffolded):** ``` -/backend # FastAPI service, uv-managed (app/ = api, core; service/repository/domain land with the data model) +/backend # FastAPI, uv-managed. app/{api/v1, services (+privacy), repositories, models, schemas, core}; migrations/ = Alembic /deploy # docker-compose.yml, Caddyfile, .env.example /.gitea/workflows # Gitea Actions: build images → Gitea registry /frontend # Next.js (pending) From 5123c8539732ba39df398aa1aaa3ae56bf5c1ea7 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:51:51 -0400 Subject: [PATCH 09/17] Add auth foundation: sessions/tokens schema, Argon2 hashing, config Two tables (sessions, user_tokens) + migration; only token *hashes* are stored, so a DB leak yields no usable credential. Argon2id password hashing and token primitives in app/core/security. Config and .env.example gain session/cookie/token TTLs, app base URL, and SMTP settings (twelve-factor). Migration verified reversible (drops the token_purpose enum) and matches the models. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/core/config.py | 16 +++ backend/app/core/security.py | 35 ++++++ backend/app/models/__init__.py | 3 + backend/app/models/auth.py | 43 ++++++++ backend/app/models/enums.py | 5 + .../1f6e54f6406a_auth_sessions_and_tokens.py | 62 +++++++++++ backend/pyproject.toml | 1 + backend/uv.lock | 103 ++++++++++++++++++ deploy/.env.example | 10 ++ 9 files changed, 278 insertions(+) create mode 100644 backend/app/core/security.py create mode 100644 backend/app/models/auth.py create mode 100644 backend/migrations/versions/1f6e54f6406a_auth_sessions_and_tokens.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index cbec505..0a8aecb 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -27,6 +27,22 @@ class Settings(BaseSettings): 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: 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/models/__init__.py b/backend/app/models/__init__.py index e9b45c6..dcc886e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ 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 @@ -25,4 +26,6 @@ __all__ = [ "Source", "Citation", "AuditEntry", + "Session", + "UserToken", ] 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/enums.py b/backend/app/models/enums.py index 933789c..41e5cb8 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -55,3 +55,8 @@ class CitationConfidence(enum.StrEnum): class AuditActorType(enum.StrEnum): user = "user" assistant = "assistant" + + +class TokenPurpose(enum.StrEnum): + email_verify = "email_verify" + password_reset = "password_reset" 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/pyproject.toml b/backend/pyproject.toml index fdc41f1..7092026 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "sqlalchemy[asyncio]>=2.0", "asyncpg>=0.30", "alembic>=1.14", + "argon2-cffi>=23.1", ] [dependency-groups] diff --git a/backend/uv.lock b/backend/uv.lock index 4c5dc4c..9c5d5de 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "alembic" @@ -46,6 +50,49 @@ 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" @@ -87,6 +134,51 @@ 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" @@ -353,6 +445,7 @@ version = "0.0.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, { name = "pydantic" }, @@ -372,6 +465,7 @@ dev = [ [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" }, @@ -388,6 +482,15 @@ dev = [ { 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" diff --git a/deploy/.env.example b/deploy/.env.example index 923efaa..9e59dac 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -24,6 +24,16 @@ S3_REGION=us-east-1 # Local: ':80' (http://localhost). Production: 'provenance.example.com' for auto-HTTPS. PROVENANCE_SITE_ADDRESS=:80 +# --- 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 From 00bfe8bfcaa23464bf5b681bbf9571a66796aecc Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:51:51 -0400 Subject: [PATCH 10/17] Add local auth: AuthProvider, mailer, sessions, /api/v1/auth Pluggable AuthProvider interface with a local (email+password) implementation, and a Mailer interface (ConsoleMailer for dev, SMTPMailer for operators). The auth service owns registration, login, opaque session issuance, email verification, and password reset (which revokes prior sessions). Endpoints under /api/v1/auth; sessions are returned as a Bearer token and set as an HttpOnly cookie. Replaces the temporary X-User-Id shim: get_current_user now resolves a real session (Bearer or cookie). The open user-bootstrap endpoint is gone (registration replaces it). App logging is configured so the ConsoleMailer's verification/reset links are visible to self-hosters. Verified end-to-end on the deploy target, including the email-verification flow. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/app/api/deps.py | 49 +++-- backend/app/api/v1/__init__.py | 3 +- backend/app/api/v1/auth.py | 81 ++++++++ backend/app/api/v1/users.py | 16 +- backend/app/integrations/__init__.py | 0 backend/app/integrations/auth/__init__.py | 0 backend/app/integrations/auth/base.py | 24 +++ backend/app/integrations/auth/local.py | 27 +++ backend/app/integrations/mailer/__init__.py | 0 backend/app/integrations/mailer/base.py | 16 ++ backend/app/integrations/mailer/console.py | 16 ++ backend/app/integrations/mailer/smtp.py | 43 +++++ backend/app/main.py | 17 ++ backend/app/schemas/auth.py | 35 ++++ backend/app/services/auth_service.py | 201 ++++++++++++++++++++ 15 files changed, 497 insertions(+), 31 deletions(-) create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/integrations/__init__.py create mode 100644 backend/app/integrations/auth/__init__.py create mode 100644 backend/app/integrations/auth/base.py create mode 100644 backend/app/integrations/auth/local.py create mode 100644 backend/app/integrations/mailer/__init__.py create mode 100644 backend/app/integrations/mailer/base.py create mode 100644 backend/app/integrations/mailer/console.py create mode 100644 backend/app/integrations/mailer/smtp.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/services/auth_service.py diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c9efb4f..637809a 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,33 +1,48 @@ -"""Shared API dependencies.""" +"""Shared API dependencies: DB session, the authenticated user, and the mailer.""" -import uuid from typing import Annotated -from fastapi import Depends, Header, HTTPException, status +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.user_service import get_user +from app.services import auth_service SessionDep = Annotated[AsyncSession, Depends(get_session)] -async def get_current_user( - session: SessionDep, - x_user_id: Annotated[uuid.UUID | None, Header()] = None, -) -> User: - """TEMPORARY pre-auth shim: identifies the caller via the ``X-User-Id`` - header. Replaced by the AuthProvider (sessions/tokens) in the auth slice. - The assistant principal will also be minted here, scoped to its user.""" - if x_user_id is None: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, "X-User-Id header required (pre-auth)" - ) - user = await get_user(session, x_user_id) +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, "unknown user") + 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/v1/__init__.py b/backend/app/api/v1/__init__.py index 7e4a596..ee7faa3 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,9 +2,10 @@ from fastapi import APIRouter -from app.api.v1 import persons, trees, users +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/users.py b/backend/app/api/v1/users.py index 6b12887..af0f451 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,21 +1,11 @@ -from fastapi import APIRouter, status +from fastapi import APIRouter -from app.api.deps import CurrentUser, SessionDep -from app.schemas.user import UserCreate, UserRead -from app.services import user_service +from app.api.deps import CurrentUser +from app.schemas.user import UserRead router = APIRouter(prefix="/users", tags=["users"]) -@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED) -async def create_user(data: UserCreate, session: SessionDep) -> UserRead: - # Open dev bootstrap until the auth slice; lets us create tree owners. - user = await user_service.create_user( - session, email=data.email, display_name=data.display_name - ) - return UserRead.model_validate(user) - - @router.get("/me", response_model=UserRead) async def read_me(current: CurrentUser) -> UserRead: return UserRead.model_validate(current) 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 index 2f3dfd6..af34fbf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,9 @@ 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 @@ -14,6 +17,19 @@ 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: @@ -29,6 +45,7 @@ def _register_error_handlers(app: FastAPI) -> None: def create_app() -> FastAPI: + _configure_logging() settings = get_settings() app = FastAPI( title=settings.app_name, 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/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() From 9f8dd960f455f9b76dffd14002eccabc2cab0d98 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:51:51 -0400 Subject: [PATCH 11/17] Test auth flows and switch core tests to session auth New auth suite covers registration, login (incl. wrong-password), email verification, password reset (old sessions + old password rejected), logout revocation, and no-enumeration on reset. Core tenancy tests now authenticate via real sessions. A capturing mailer makes email flows assertable. 13 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- backend/tests/conftest.py | 48 ++++++++++++++- backend/tests/test_auth.py | 106 +++++++++++++++++++++++++++++++++ backend/tests/test_core_api.py | 58 +++++++----------- 3 files changed, 171 insertions(+), 41 deletions(-) create mode 100644 backend/tests/test_auth.py diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9abe1b8..1682de0 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,28 +3,49 @@ 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. +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: - import pytest - pytest.skip("TEST_DATABASE_URL not set") engine = create_async_engine(TEST_DATABASE_URL) @@ -38,10 +59,31 @@ async def client(): 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 index 5da62cb..c874feb 100644 --- a/backend/tests/test_core_api.py +++ b/backend/tests/test_core_api.py @@ -1,91 +1,73 @@ """End-to-end coverage of the core data model through the API: tenancy, the -privacy seam, and the pre-auth actor shim.""" +privacy seam, and real session auth.""" - -async def _create_user(client, email: str) -> str: - resp = await client.post( - "/api/v1/users", json={"email": email, "display_name": email} - ) - assert resp.status_code == 201, resp.text - return resp.json()["id"] +from tests.conftest import auth, register async def test_tree_and_person_flow(client): - user_id = await _create_user(client, "keeper@example.com") - headers = {"X-User-Id": user_id} + token = await register(client, "keeper@example.com") resp = await client.post( - "/api/v1/trees", json={"name": "Smith Family", "visibility": "private"}, headers=headers + "/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" - assert tree["owner_id"] == user_id tree_id = tree["id"] - resp = await client.get("/api/v1/trees", headers=headers) + 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=headers, + 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=headers) + 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 _create_user(client, "owner@example.com") - other = await _create_user(client, "stranger@example.com") + 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={"X-User-Id": owner}, + "/api/v1/trees", json={"name": "Private", "visibility": "private"}, headers=auth(owner) ) tree_id = resp.json()["id"] - # A non-member cannot view a private tree, nor list its people. - resp = await client.get(f"/api/v1/trees/{tree_id}", headers={"X-User-Id": other}) + 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={"X-User-Id": other}) + 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 _create_user(client, "owner2@example.com") - viewer = await _create_user(client, "viewer2@example.com") + 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={"X-User-Id": owner}, + "/api/v1/trees", json={"name": "Public", "visibility": "public"}, headers=auth(owner) ) tree_id = resp.json()["id"] - # Visible to a non-member... - resp = await client.get(f"/api/v1/trees/{tree_id}", headers={"X-User-Id": viewer}) + resp = await client.get(f"/api/v1/trees/{tree_id}", headers=auth(viewer)) assert resp.status_code == 200 - # ...but not writable (not an editor). resp = await client.post( - f"/api/v1/trees/{tree_id}/persons", json={"given": "Nope"}, - headers={"X-User-Id": viewer}, + f"/api/v1/trees/{tree_id}/persons", json={"given": "Nope"}, headers=auth(viewer) ) assert resp.status_code == 403 -async def test_duplicate_email_conflicts(client): - await _create_user(client, "dupe@example.com") - resp = await client.post("/api/v1/users", json={"email": "dupe@example.com"}) - assert resp.status_code == 409 - - -async def test_auth_required_without_header(client): +async def test_auth_required_without_token(client): resp = await client.get("/api/v1/trees") assert resp.status_code == 401 From e2edd4b2f113141ed2fd07e3bc070240540f94f7 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 10:51:51 -0400 Subject: [PATCH 12/17] Document landed local auth in CLAUDE.md and ARCHITECTURE Records the auth model (Argon2, opaque sessions, Bearer/cookie, email verify/reset behind AuthProvider/Mailer), supersedes the interim X-User-Id note, and adds integrations/ to the backend layout. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- CLAUDE.md | 4 ++-- docs/ARCHITECTURE.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3f1af02..5eac21b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,13 +38,13 @@ 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, core}; migrations/ = Alembic +/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 — not yet scaffolded (Phase 0, after the deploy story) ``` -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) and its initial migration have landed; local auth and the frontend are next. A temporary `X-User-Id` header shim stands in for auth until that slice. 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) and **local auth** (Argon2 passwords, backend-issued sessions, email verify/reset behind the `AuthProvider` interface) have landed. API auth uses a session token (Bearer header or HttpOnly cookie). The **frontend scaffold** is next; OIDC/social auth is Phase 5. Keep this section current as the tree grows. ## Where to start diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b6b3bc6..611ec73 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -147,7 +147,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. -- *Phase 0 interim:* until the auth slice lands, the API identifies the caller via a temporary `X-User-Id` header shim (replaced by real sessions/tokens), and account creation is an open dev bootstrap. Every write still records an attributable actor in the audit log. +- *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 @@ -179,7 +179,7 @@ Jobs are idempotent and retryable; an external failure degrades gracefully rathe **Repository layout (as scaffolded):** ``` -/backend # FastAPI, uv-managed. app/{api/v1, services (+privacy), repositories, models, schemas, core}; migrations/ = Alembic +/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 (pending) From a5a79f01a792379172bc274096c3e6cab5ff6086 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 11:03:07 -0400 Subject: [PATCH 13/17] Scaffold Next.js frontend with generated OpenAPI client and core views Next.js (App Router) + React 19 + TypeScript + Tailwind v4, with shadcn-style UI primitives (Button, Input, Card, Label via cva/tailwind-merge). A typed API client is generated from the backend OpenAPI spec with openapi-typescript + openapi-fetch (npm run gen:api); the committed openapi.json/schema.d.ts are the snapshot. Views: landing, login, register, tree list + create, and tree detail with person list + create. Auth rides the same-origin HttpOnly session cookie the backend sets (Caddy proxies /api/*), so no token handling in JS. Built as a standalone container. Mobile-first; next build is clean. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Justin Paul --- frontend/.dockerignore | 7 + frontend/.gitignore | 6 + frontend/Dockerfile | 24 + frontend/app/globals.css | 19 + frontend/app/layout.tsx | 34 + frontend/app/login/page.tsx | 74 ++ frontend/app/page.tsx | 25 + frontend/app/register/page.tsx | 82 ++ frontend/app/trees/[id]/page.tsx | 96 ++ frontend/app/trees/page.tsx | 99 ++ frontend/components/ui/button.tsx | 33 + frontend/components/ui/card.tsx | 24 + frontend/components/ui/input.tsx | 17 + frontend/components/ui/label.tsx | 11 + frontend/lib/api/client.ts | 10 + frontend/lib/api/schema.d.ts | 785 ++++++++++++ frontend/lib/utils.ts | 6 + frontend/next.config.mjs | 7 + frontend/openapi.json | 936 ++++++++++++++ frontend/package-lock.json | 1926 +++++++++++++++++++++++++++++ frontend/package.json | 32 + frontend/postcss.config.mjs | 7 + frontend/public/.gitkeep | 0 frontend/tsconfig.json | 21 + 24 files changed, 4281 insertions(+) create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/app/register/page.tsx create mode 100644 frontend/app/trees/[id]/page.tsx create mode 100644 frontend/app/trees/page.tsx create mode 100644 frontend/components/ui/button.tsx create mode 100644 frontend/components/ui/card.tsx create mode 100644 frontend/components/ui/input.tsx create mode 100644 frontend/components/ui/label.tsx create mode 100644 frontend/lib/api/client.ts create mode 100644 frontend/lib/api/schema.d.ts create mode 100644 frontend/lib/utils.ts create mode 100644 frontend/next.config.mjs create mode 100644 frontend/openapi.json create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/.gitkeep create mode 100644 frontend/tsconfig.json 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) => ( +