From ed13e6939dc85e0830efbf1474b72919f97b071d Mon Sep 17 00:00:00 2001 From: Neo Date: Thu, 2 Jul 2026 10:18:25 +0000 Subject: [PATCH 1/8] chore: initial Tars boilerplate - FastAPI 0.128+ with async lifespan - Pydantic V2 + pydantic-settings for .env config - GCP-compatible JSON logging (GcpJsonFormatter with severity field) - /ht health check endpoint (Kubernetes liveness/readiness probe) - UV-managed environment (PEP 621 pyproject.toml, package = false) - ruff linting + formatting (ruff.toml) - Dockerfile (python:3.12-slim + uv 0.10.6) - bors.toml merge management - pytest AsyncClient test for /ht Co-authored-by: Aditya Raj --- .env.example | 2 + .gitignore | 9 + Dockerfile | 31 +++ app/__init__.py | 0 app/core/__init__.py | 0 app/core/config.py | 68 ++++++ app/main.py | 33 +++ bors.toml | 6 + pyproject.toml | 41 ++++ ruff.toml | 14 ++ tests/__init__.py | 0 tests/test_health.py | 12 + uv.lock | 567 +++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 783 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/main.py create mode 100644 bors.toml create mode 100644 pyproject.toml create mode 100644 ruff.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_health.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e431ba3 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DEPLOYMENT_ENV=DEV +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bd7386 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +.env +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +dist/ +*.egg-info/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f451ab7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Base image: python:3.12-slim is the standard across all SpotDraft FastAPI services +# (oogway, neo-sites-gateway, tigress, kratos, mission-control-backend). +# +# Chainguard migration note: django-rest-api uses a SpotDraft-published sanctioned +# builder (ghcr.io/spotdraft/python-builder) backed by cgr.dev/chainguard-private/python:3.12-dev +# with SafeDep PMG malware scanning. This is worth adopting for Tars too, but requires +# the platform/security team to publish a runner image for this service first. +# Track as a follow-up ticket. +FROM python:3.12-slim + +# Prevent .pyc files, enable unbuffered stdout — standard across all SpotDraft FastAPI services +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_NO_DEV=1 \ + UV_PYTHON_DOWNLOADS=0 + +WORKDIR /app + +# Copy uv binary from its official image (pattern used in neo-sites-gateway, oogway) +COPY --from=ghcr.io/astral-sh/uv:0.10.6 /uv /uvx /bin/ + +# Install deps first (layer cached until pyproject.toml or uv.lock changes) +COPY pyproject.toml uv.lock ./ +RUN uv sync --locked --no-install-project + +# Copy application code +COPY app ./app + +EXPOSE 8000 + +CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..c550c79 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,68 @@ +import logging +from typing import Any, ClassVar + +from pydantic_settings import BaseSettings, SettingsConfigDict +from pythonjsonlogger import json as json_logger + + +class GcpJsonFormatter(json_logger.JsonFormatter): + """Adds 'severity' field so GCP Log Explorer reads log levels correctly. + + GKE's logging agent maps the JSON 'severity' key to the log entry severity. + Without it, all logs appear as INFO in Cloud Logging regardless of level. + Pattern copied from oogway/app/core/config.py. + """ + + def add_fields( + self, + log_data: dict[str, Any], + record: logging.LogRecord, + message_dict: dict[str, Any], + ) -> None: + super().add_fields(log_data, record, message_dict) + log_data["severity"] = record.levelname + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + DEPLOYMENT_ENV: str = "DEV" + LOG_LEVEL: str = "INFO" + API_V1_STR: str = "/api/v1" + + +settings = Settings() + + +class LoggingConfig: + """dictConfig-compatible structured logging. Pattern from oogway/neo-sites-gateway.""" + + version = 1 + disable_existing_loggers = False + formatters: ClassVar[dict] = { + "json": { + "()": "app.core.config.GcpJsonFormatter", + "fmt": "%(asctime)s %(levelname).4s %(name)-12s %(message)s", + }, + } + handlers: ClassVar[dict] = { + "console": { + "formatter": "json", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + } + loggers: ClassVar[dict] = { + "uvicorn.access": {"handlers": ["console"], "level": settings.LOG_LEVEL}, + "": {"handlers": ["console"], "level": settings.LOG_LEVEL}, + } + + @classmethod + def to_dict(cls) -> dict: + return { + "version": cls.version, + "disable_existing_loggers": cls.disable_existing_loggers, + "formatters": cls.formatters, + "handlers": cls.handlers, + "loggers": cls.loggers, + } diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..09880fe --- /dev/null +++ b/app/main.py @@ -0,0 +1,33 @@ +import logging +from contextlib import asynccontextmanager +from logging.config import dictConfig + +from fastapi import FastAPI +from fastapi.middleware.gzip import GZipMiddleware + +from app.core.config import LoggingConfig, settings + +dictConfig(LoggingConfig.to_dict()) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + logger.info("tars starting", extra={"env": settings.DEPLOYMENT_ENV}) + yield + logger.info("tars shutting down") + + +app = FastAPI( + title="tars", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware(GZipMiddleware) + + +@app.get("/ht", tags=["ops"]) +async def health_check() -> dict[str, str]: + """Health check endpoint used by Kubernetes liveness and readiness probes.""" + return {"status": "ok"} diff --git a/bors.toml b/bors.toml new file mode 100644 index 0000000..56b4e47 --- /dev/null +++ b/bors.toml @@ -0,0 +1,6 @@ +status = ["lint", "test"] +pr_status = ["call-central-workflow / security-report"] +use_squash_merge = true +use_codeowners = true +required_approvals = 1 +delete_merged_branches = true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c8f61bd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "tars" +version = "0.1.0" +description = "Tars microservice" +authors = [{ name = "SpotDraft", email = "engineering@spotdraft.com" }] +requires-python = ">=3.12,<3.13" + +dependencies = [ + # Web framework + ASGI server + "fastapi>=0.128.0", + "uvicorn[standard]>=0.35.0", + + # Data validation (Pydantic V2 — team standard across all services) + "pydantic>=2.11.0", + "pydantic-settings>=2.10.0", # .env config via BaseSettings + + # Structured JSON logging (needed from first deployment for GCP Log Explorer) + "python-json-logger>=3.2.0", +] + +[dependency-groups] +dev = [ + # Testing + "pytest>=9.0.0", + "pytest-asyncio>=1.3.0", + "httpx>=0.28.0", # AsyncClient for testing FastAPI without a live server + # Code quality + "ruff>=0.14.0", + "mypy>=1.19.0", +] + +[tool.uv] +package = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +markers = [ + "unit: fast unit tests", + "integration: tests requiring external services", +] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..4bc7e6c --- /dev/null +++ b/ruff.toml @@ -0,0 +1,14 @@ +line-length = 100 +target-version = "py312" + +[lint] +select = ["E", "W", "F", "I", "B", "C4", "UP", "N", "SIM", "ARG", "PIE", "RET", "RUF"] +ignore = ["E501", "B008", "B904"] +exclude = [".venv", "__pycache__"] + +[lint.isort] +known-first-party = ["app"] + +[format] +quote-style = "double" +indent-style = "space" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..a01c7b9 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,12 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.mark.asyncio +async def test_health_check(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + r = await client.get("/ht") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1727a52 --- /dev/null +++ b/uv.lock @@ -0,0 +1,567 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[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.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.0" +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/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + +[[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/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, +] + +[[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 = "librt" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, + { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, + { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "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 = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "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 = "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/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[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.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +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 = "python-json-logger" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, +] + +[[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/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tars" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-json-logger" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.128.0" }, + { name = "pydantic", specifier = ">=2.11.0" }, + { name = "pydantic-settings", specifier = ">=2.10.0" }, + { name = "python-json-logger", specifier = ">=3.2.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.0" }, + { name = "mypy", specifier = ">=1.19.0" }, + { name = "pytest", specifier = ">=9.0.0" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "ruff", specifier = ">=0.14.0" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "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/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + +[[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/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, +] + +[[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/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { 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 6e8965a289aafc665c4f1de4c4508c52981f9b98 Mon Sep 17 00:00:00 2001 From: Neo Date: Thu, 2 Jul 2026 10:56:11 +0000 Subject: [PATCH 2/8] refactor: move logging config to app/core/log_config.py - Extract GcpJsonFormatter and LoggingConfig from config.py into a dedicated app/core/log_config.py - config.py now only contains Settings (single responsibility) - Update formatter dotted-path reference from app.core.config to app.core.log_config - Update main.py import accordingly Naming: log_config.py avoids shadowing the stdlib logging module Co-authored-by: Aditya Raj --- app/core/config.py | 56 -------------------------------------- app/core/log_config.py | 62 ++++++++++++++++++++++++++++++++++++++++++ app/main.py | 3 +- 3 files changed, 64 insertions(+), 57 deletions(-) create mode 100644 app/core/log_config.py diff --git a/app/core/config.py b/app/core/config.py index c550c79..1c382c7 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,26 +1,4 @@ -import logging -from typing import Any, ClassVar - from pydantic_settings import BaseSettings, SettingsConfigDict -from pythonjsonlogger import json as json_logger - - -class GcpJsonFormatter(json_logger.JsonFormatter): - """Adds 'severity' field so GCP Log Explorer reads log levels correctly. - - GKE's logging agent maps the JSON 'severity' key to the log entry severity. - Without it, all logs appear as INFO in Cloud Logging regardless of level. - Pattern copied from oogway/app/core/config.py. - """ - - def add_fields( - self, - log_data: dict[str, Any], - record: logging.LogRecord, - message_dict: dict[str, Any], - ) -> None: - super().add_fields(log_data, record, message_dict) - log_data["severity"] = record.levelname class Settings(BaseSettings): @@ -32,37 +10,3 @@ class Settings(BaseSettings): settings = Settings() - - -class LoggingConfig: - """dictConfig-compatible structured logging. Pattern from oogway/neo-sites-gateway.""" - - version = 1 - disable_existing_loggers = False - formatters: ClassVar[dict] = { - "json": { - "()": "app.core.config.GcpJsonFormatter", - "fmt": "%(asctime)s %(levelname).4s %(name)-12s %(message)s", - }, - } - handlers: ClassVar[dict] = { - "console": { - "formatter": "json", - "class": "logging.StreamHandler", - "stream": "ext://sys.stdout", - }, - } - loggers: ClassVar[dict] = { - "uvicorn.access": {"handlers": ["console"], "level": settings.LOG_LEVEL}, - "": {"handlers": ["console"], "level": settings.LOG_LEVEL}, - } - - @classmethod - def to_dict(cls) -> dict: - return { - "version": cls.version, - "disable_existing_loggers": cls.disable_existing_loggers, - "formatters": cls.formatters, - "handlers": cls.handlers, - "loggers": cls.loggers, - } diff --git a/app/core/log_config.py b/app/core/log_config.py new file mode 100644 index 0000000..0e31210 --- /dev/null +++ b/app/core/log_config.py @@ -0,0 +1,62 @@ +import logging +from typing import Any, ClassVar + +from pythonjsonlogger import json as json_logger + +from app.core.config import settings + + +class GcpJsonFormatter(json_logger.JsonFormatter): + """Adds 'severity' field so GCP Log Explorer reads log levels correctly. + + GKE's logging agent maps the JSON 'severity' key to the log entry severity. + Without it, all logs appear as INFO in Cloud Logging regardless of level. + Pattern copied from oogway/app/core/config.py. + """ + + def add_fields( + self, + log_data: dict[str, Any], + record: logging.LogRecord, + message_dict: dict[str, Any], + ) -> None: + super().add_fields(log_data, record, message_dict) + log_data["severity"] = record.levelname + + +class LoggingConfig: + """dictConfig-compatible structured JSON logging for GCP/Kubernetes. + + Call ``logging.config.dictConfig(LoggingConfig.to_dict())`` once at + application startup (in ``main.py``) before any loggers are created. + """ + + version = 1 + disable_existing_loggers = False + formatters: ClassVar[dict] = { + "json": { + "()": "app.core.log_config.GcpJsonFormatter", + "fmt": "%(asctime)s %(levelname).4s %(name)-12s %(message)s", + }, + } + handlers: ClassVar[dict] = { + "console": { + "formatter": "json", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + } + loggers: ClassVar[dict] = { + "uvicorn.access": {"handlers": ["console"], "level": settings.LOG_LEVEL}, + "": {"handlers": ["console"], "level": settings.LOG_LEVEL}, + } + + @classmethod + def to_dict(cls) -> dict: + return { + "version": cls.version, + "disable_existing_loggers": cls.disable_existing_loggers, + "formatters": cls.formatters, + "handlers": cls.handlers, + "loggers": cls.loggers, + } diff --git a/app/main.py b/app/main.py index 09880fe..450cfb0 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,8 @@ from fastapi import FastAPI from fastapi.middleware.gzip import GZipMiddleware -from app.core.config import LoggingConfig, settings +from app.core.config import settings +from app.core.log_config import LoggingConfig dictConfig(LoggingConfig.to_dict()) logger = logging.getLogger(__name__) From d426970bfd643568810556c3b5fb9f7ba2f59bfd Mon Sep 17 00:00:00 2001 From: Aditya Raj Date: Thu, 2 Jul 2026 17:05:59 +0530 Subject: [PATCH 3/8] Added folder structure --- app/agreement/__init__.py | 0 app/agreement/data/__init__.py | 0 app/agreement/data/postgres/__init__.py | 0 app/agreement/domain/__init__.py | 0 app/agreement/domain/use_cases/__init__.py | 0 app/agreement/models.py | 0 app/agreement/presentation/__init__.py | 0 app/clickwrap/__init__.py | 0 app/clickwrap/data/__init__.py | 0 app/clickwrap/data/postgres/__init__.py | 0 app/clickwrap/domain/__init__.py | 0 app/clickwrap/domain/use_cases/__init__.py | 0 app/clickwrap/models.py | 0 app/clickwrap/presentation/__init__.py | 0 app/consent/__init__.py | 0 app/consent/data/__init__.py | 0 app/consent/data/firestore/__init__.py | 0 app/consent/domain/__init__.py | 0 app/consent/domain/use_cases/__init__.py | 0 app/consent/presentation/__init__.py | 0 app/db/__init__.py | 0 app/db/firestore.py | 0 app/db/postgres.py | 0 app/legal_hub/__init__.py | 0 app/legal_hub/data/__init__.py | 0 app/legal_hub/data/postgres/__init__.py | 0 app/legal_hub/domain/__init__.py | 0 app/legal_hub/domain/use_cases/__init__.py | 0 app/legal_hub/models.py | 0 app/legal_hub/presentation/__init__.py | 0 tests/agreement/__init__.py | 0 tests/clickwrap/__init__.py | 0 tests/consent/__init__.py | 0 tests/legal_hub/__init__.py | 0 34 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/agreement/__init__.py create mode 100644 app/agreement/data/__init__.py create mode 100644 app/agreement/data/postgres/__init__.py create mode 100644 app/agreement/domain/__init__.py create mode 100644 app/agreement/domain/use_cases/__init__.py create mode 100644 app/agreement/models.py create mode 100644 app/agreement/presentation/__init__.py create mode 100644 app/clickwrap/__init__.py create mode 100644 app/clickwrap/data/__init__.py create mode 100644 app/clickwrap/data/postgres/__init__.py create mode 100644 app/clickwrap/domain/__init__.py create mode 100644 app/clickwrap/domain/use_cases/__init__.py create mode 100644 app/clickwrap/models.py create mode 100644 app/clickwrap/presentation/__init__.py create mode 100644 app/consent/__init__.py create mode 100644 app/consent/data/__init__.py create mode 100644 app/consent/data/firestore/__init__.py create mode 100644 app/consent/domain/__init__.py create mode 100644 app/consent/domain/use_cases/__init__.py create mode 100644 app/consent/presentation/__init__.py create mode 100644 app/db/__init__.py create mode 100644 app/db/firestore.py create mode 100644 app/db/postgres.py create mode 100644 app/legal_hub/__init__.py create mode 100644 app/legal_hub/data/__init__.py create mode 100644 app/legal_hub/data/postgres/__init__.py create mode 100644 app/legal_hub/domain/__init__.py create mode 100644 app/legal_hub/domain/use_cases/__init__.py create mode 100644 app/legal_hub/models.py create mode 100644 app/legal_hub/presentation/__init__.py create mode 100644 tests/agreement/__init__.py create mode 100644 tests/clickwrap/__init__.py create mode 100644 tests/consent/__init__.py create mode 100644 tests/legal_hub/__init__.py diff --git a/app/agreement/__init__.py b/app/agreement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agreement/data/__init__.py b/app/agreement/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agreement/data/postgres/__init__.py b/app/agreement/data/postgres/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agreement/domain/__init__.py b/app/agreement/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agreement/domain/use_cases/__init__.py b/app/agreement/domain/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agreement/models.py b/app/agreement/models.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agreement/presentation/__init__.py b/app/agreement/presentation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/clickwrap/__init__.py b/app/clickwrap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/clickwrap/data/__init__.py b/app/clickwrap/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/clickwrap/data/postgres/__init__.py b/app/clickwrap/data/postgres/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/clickwrap/domain/__init__.py b/app/clickwrap/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/clickwrap/domain/use_cases/__init__.py b/app/clickwrap/domain/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/clickwrap/models.py b/app/clickwrap/models.py new file mode 100644 index 0000000..e69de29 diff --git a/app/clickwrap/presentation/__init__.py b/app/clickwrap/presentation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/consent/__init__.py b/app/consent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/consent/data/__init__.py b/app/consent/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/consent/data/firestore/__init__.py b/app/consent/data/firestore/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/consent/domain/__init__.py b/app/consent/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/consent/domain/use_cases/__init__.py b/app/consent/domain/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/consent/presentation/__init__.py b/app/consent/presentation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/firestore.py b/app/db/firestore.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/postgres.py b/app/db/postgres.py new file mode 100644 index 0000000..e69de29 diff --git a/app/legal_hub/__init__.py b/app/legal_hub/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/legal_hub/data/__init__.py b/app/legal_hub/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/legal_hub/data/postgres/__init__.py b/app/legal_hub/data/postgres/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/legal_hub/domain/__init__.py b/app/legal_hub/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/legal_hub/domain/use_cases/__init__.py b/app/legal_hub/domain/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/legal_hub/models.py b/app/legal_hub/models.py new file mode 100644 index 0000000..e69de29 diff --git a/app/legal_hub/presentation/__init__.py b/app/legal_hub/presentation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/agreement/__init__.py b/tests/agreement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/clickwrap/__init__.py b/tests/clickwrap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/consent/__init__.py b/tests/consent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/legal_hub/__init__.py b/tests/legal_hub/__init__.py new file mode 100644 index 0000000..e69de29 From 3b27c793733d74bce0ac78f85b68724c63f93090 Mon Sep 17 00:00:00 2001 From: Aditya Raj Date: Mon, 6 Jul 2026 18:33:17 +0530 Subject: [PATCH 4/8] Adds the initial PostgreSQL layer for consent microservice --- alembic.ini | 65 +++++ alembic/README | 1 + alembic/env.py | 89 +++++++ alembic/script.py.mako | 26 ++ alembic/versions/.gitkeep | 0 app/core/config.py | 7 + app/db/enums.py | 32 +++ app/db/models.py | 485 ++++++++++++++++++++++++++++++++++++++ app/db/postgres.py | 85 +++++++ pyproject.toml | 5 + uv.lock | 122 +++++++++- 11 files changed, 911 insertions(+), 6 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/.gitkeep create mode 100644 app/db/enums.py create mode 100644 app/db/models.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..3d85bbd --- /dev/null +++ b/alembic.ini @@ -0,0 +1,65 @@ +# Alembic configuration file. +# See https://alembic.sqlalchemy.org/en/latest/tutorial.html + +[alembic] +# Path to the alembic scripts directory. +script_location = alembic + +# Migration file template +file_template = %%(year)s%%(month)s%%(day)s_%%(rev)s_%%(slug)s + +# Timezone for file timestamps +timezone = UTC + +# Maximum length of revision identifiers +truncate_slug_length = 40 + +# The SQLAlchemy URL is intentionally **not** set here. +# env.py reads DATABASE_URL from the Settings object so .env is the +# single source of truth. If you need to pass a URL on the CLI you can +# use: alembic -x sqlalchemy.url="postgresql+asyncpg://..." upgrade head + +[post_write_hooks] +# Run black + ruff after generating a new revision file. +# Requires black and ruff to be installed (already in [dependency-groups.dev]). +hooks = black, ruff +black.type = console_scripts +black.entrypoint = black +black.options = REVISION_SCRIPT_FILENAME +ruff.type = console_scripts +ruff.entrypoint = ruff +ruff.options = check --fix REVISION_SCRIPT_FILENAME + +[loggers] +keys = root, sqlalchemy, alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +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/alembic/README b/alembic/README new file mode 100644 index 0000000..e1b36d8 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async setup. diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..f8333a9 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,89 @@ +""" +Alembic migration environment for the tars service. + +Async-aware (asyncpg + SQLAlchemy 2.x). +Reads DATABASE_URL from app.core.config.Settings so .env is the single +source of truth — no sqlalchemy.url in alembic.ini. + +Import all ORM models below the Base import so that +Base.metadata knows about them when autogenerating migrations. +""" + +import asyncio +import logging +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +# --------------------------------------------------------------------------- +# app imports — must be resolvable from the project root +# --------------------------------------------------------------------------- +from app.core.config import settings +from app.db.postgres import Base + +# noqa: F401 — side-effect imports that populate Base.metadata +import app.db.models # noqa: F401 + +# --------------------------------------------------------------------------- +# Alembic Config +# --------------------------------------------------------------------------- +alembic_config = context.config + +if alembic_config.config_file_name: + fileConfig(alembic_config.config_file_name) + +target_metadata = Base.metadata + +logger = logging.getLogger("alembic.env") + + +# --------------------------------------------------------------------------- +# Offline mode — generates SQL without a live DB connection +# --------------------------------------------------------------------------- + + +def run_migrations_offline() -> None: + context.configure( + url=settings.DATABASE_URL, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +# --------------------------------------------------------------------------- +# Online mode — runs migrations against a live DB +# --------------------------------------------------------------------------- + + +def do_run_migrations(connection) -> None: # type: ignore[no-untyped-def] + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = create_async_engine(settings.DATABASE_URL, echo=False) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..01b090c --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py index 1c382c7..b9b446e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -8,5 +8,12 @@ class Settings(BaseSettings): LOG_LEVEL: str = "INFO" API_V1_STR: str = "/api/v1" + # Postgres — async DSN (asyncpg driver) + # Format: postgresql+asyncpg://user:password@host:port/dbname + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/tars" + + # Used as the default value for domain_setting.default_domain + CLUSTER_ID: str = "IN" + settings = Settings() diff --git a/app/db/enums.py b/app/db/enums.py new file mode 100644 index 0000000..cc2151a --- /dev/null +++ b/app/db/enums.py @@ -0,0 +1,32 @@ +from enum import StrEnum + + +class AgreementUiType(StrEnum): + """Maps Django's ClickwrapType. Controls the UI presentation of the packet.""" + + SINGLE_CHECKBOX = "SINGLE_CHECKBOX" + MULTIPLE_CHECKBOX = "MULTIPLE_CHECKBOX" + INLINE = "INLINE" + + +class DomainStatusType(StrEnum): + """Maps Django's ClickwrapDomainStatusType. Verification state of a custom domain.""" + + DRAFT = "DRAFT" + VERIFIED = "VERIFIED" + + +class AgreementVersionStatus(StrEnum): + """Maps Django's ClickwrapAgreementVersionStatusType.""" + + DRAFT = "DRAFT" + PUBLISHED = "PUBLISHED" + PAST_PUBLISHED = "PAST_PUBLISHED" + + +class AgreementVersionSource(StrEnum): + """Maps Django's ClickwrapAgreementVersionSourceType. How the version content was created.""" + + EDIT = "EDIT" + EDITOR = "EDITOR" + UPLOAD = "UPLOAD" diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..2938332 --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,485 @@ +""" +Postgres ORM models for the tars control-plane. + +All 10 tables live in this single file — same pattern as Django's per-app +models.py. Module packages (clickwrap/, agreement/, legal_hub/) import from +here for queries; they do not define their own ORM models. + +Table mapping from Django clickwraps/models.py: + Clickwrap -> packet + ClickwrapSettings -> packet_settings + ClickwrapDomainSetting -> domain_setting + ClickwrapAgreementWhitelabelConfig -> whitelabel_config + ClickwrapAgreementMapping -> packet_agreement_mapping + ClickwrapAgreement -> agreement + ClickwrapAgreementVersion -> agreement_version + ClickwrapLegalHub -> legal_hub + ClickwrapLegalHubAgreementMapping -> legal_hub_agreement_mapping + ClickwrapLegalHubAgreementCustomURLMapping -> legal_hub_custom_url_mapping + +Not migrated (replaced by Firestore): + ClickwrapConsent, ClickwrapUser, ClickwrapConsentAgreementVersionMapping + +FK policy: + - Tables defined in this file use DB-level ForeignKey constraints. + - Cross-service references (workspace_id, org_user_id, etc.) are raw + BigInteger columns — integrity enforced at the application layer. +""" + +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + Boolean, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import ARRAY, JSON, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.enums import ( + AgreementUiType, + AgreementVersionSource, + AgreementVersionStatus, + DomainStatusType, +) +from app.db.postgres import RuntimeBaseModel, SoftDeleteMixin + +# --------------------------------------------------------------------------- +# packet_settings (Django: ClickwrapSettings) +# Holds UI / domain configuration for a packet. Created before the packet +# and referenced via a FK on packet. 1:1 relationship. +# --------------------------------------------------------------------------- + + +class PacketSettings(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "packet_settings" + + agreement_ui_type: Mapped[str] = mapped_column( + String(50), + nullable=False, + default=AgreementUiType.SINGLE_CHECKBOX, + server_default=AgreementUiType.SINGLE_CHECKBOX, + comment="Maps ClickwrapType — controls SDK presentation", + ) + clickwrap_texts: Mapped[dict | None] = mapped_column(JSON, nullable=True) + whitelisted_domains: Mapped[list] = mapped_column( + ARRAY(Text), nullable=False, server_default="{}" + ) + show_audit_click_status: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + send_executed_audit_email: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + allow_all_domains: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + + +# --------------------------------------------------------------------------- +# packet (Django: Clickwrap) +# Top-level container. Holds a reference to its settings via packet_settings_id. +# contract_type FK removed per design decision. +# --------------------------------------------------------------------------- + + +class Packet(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "packet" + + name: Mapped[str] = mapped_column(String(100), nullable=False) + name_slug: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + public_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + unique=True, + default=uuid.uuid4, + comment="Stable public identifier exposed to SDK callers", + ) + # FK to packet_settings — same table, DB-level constraint + packet_settings_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("packet_settings.id"), nullable=False + ) + updated_by_org_user_at: Mapped[datetime | None] = mapped_column( + nullable=True, + comment="Last time an org user explicitly saved changes", + ) + + __table_args__ = ( + # Maps Django's clickwrap_name_unique_per_workspace + UniqueConstraint( + "workspace_id", + "name_slug", + name="packet_name_unique_per_workspace", + postgresql_where="is_deleted = false", + ), + # Maps Django's clickwrap_workspace_index + Index("packet_workspace_idx", "workspace_id"), + # Maps Django's clickwrap_name_slug_gin_index + Index("packet_name_slug_gin_idx", "name_slug", postgresql_using="gin"), + ) + + +# --------------------------------------------------------------------------- +# domain_setting (Django: ClickwrapDomainSetting) +# Custom domain verification record per workspace. +# --------------------------------------------------------------------------- + + +class DomainSetting(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "domain_setting" + + custom_domain: Mapped[str | None] = mapped_column(String(50), nullable=True) + custom_domain_status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default=DomainStatusType.DRAFT, + server_default=DomainStatusType.DRAFT, + ) + default_domain: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment=( + "Set by the service from settings.CLUSTER_ID on creation. " + "Format: clickwrap.{cluster_id}.spotdraft.com" + ), + ) + + __table_args__ = ( + # Maps Django's custom_domain_unique_per_workspace + UniqueConstraint( + "workspace_id", + "custom_domain", + name="domain_setting_custom_domain_unique_per_workspace", + postgresql_where="is_deleted = false", + ), + # Maps Django's cwd_index_workspace_index + Index("domain_setting_workspace_idx", "workspace_id"), + # Maps Django's cwd_index_custom_domain + Index("domain_setting_custom_domain_idx", "custom_domain"), + ) + + +# --------------------------------------------------------------------------- +# whitelabel_config (Django: ClickwrapAgreementWhitelabelConfig) +# GCS paths stored as Text — same as what Django FileField persists in the DB. +# Upload logic and favicon validation live in the use-case / Pydantic layer. +# --------------------------------------------------------------------------- + + +class WhitelabelConfig(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "whitelabel_config" + + # GCS object path — equivalent to Django FileField column value + company_logo: Mapped[str] = mapped_column( + Text, nullable=False, comment="GCS object path for company logo" + ) + logo_redirect_url: Mapped[str | None] = mapped_column(Text, nullable=True) + custom_styles: Mapped[dict] = mapped_column( + JSON, nullable=False, comment="Brand CSS overrides e.g. primary_color" + ) + is_active: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + header_text: Mapped[str | None] = mapped_column( + String(100), nullable=True, default="Legal Hub" + ) + brand_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + add_footer: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + display_dropdown_and_download: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + display_published_agreements: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + # GCS object path — equivalent to Django FileField column value + # FaviconIconValidator moves to Pydantic schema / use case + favicon_icon: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="GCS object path for favicon (.ico)" + ) + + __table_args__ = ( + # Maps Django's unique_clickwrap_agreement_config_per_workspace + UniqueConstraint( + "workspace_id", + "is_active", + name="whitelabel_config_unique_per_workspace", + postgresql_where="is_deleted = false", + ), + # Maps Django's unnamed (tenant_workspace, is_active) index + Index("whitelabel_config_workspace_is_active_idx", "workspace_id", "is_active"), + ) + + +# --------------------------------------------------------------------------- +# agreement (Django: ClickwrapAgreement) +# Reusable agreement entity. Can belong to multiple packets via +# packet_agreement_mapping. +# --------------------------------------------------------------------------- + + +class Agreement(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "agreement" + + url_slug: Mapped[str | None] = mapped_column(String(100), nullable=True) + header_code: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="Custom HTML injected into the agreement header" + ) + footer_code: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="Custom HTML injected into the agreement footer" + ) + + __table_args__ = ( + # Maps Django's unique_url_slug_per_workspace + UniqueConstraint( + "workspace_id", + "url_slug", + name="agreement_url_slug_unique_per_workspace", + postgresql_where="is_deleted = false", + ), + # No explicit indexes in Django for this table — none added. + ) + + +# --------------------------------------------------------------------------- +# packet_agreement_mapping (Django: ClickwrapAgreementMapping) +# Junction table linking packets to their agreements. +# --------------------------------------------------------------------------- + + +class PacketAgreementMapping(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "packet_agreement_mapping" + + packet_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("packet.id"), nullable=False + ) + agreement_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("agreement.id"), nullable=False + ) + + __table_args__ = ( + # Maps Django's clickwrap_agreement_mapping_unique + UniqueConstraint( + "packet_id", + "agreement_id", + name="packet_agreement_mapping_unique", + postgresql_where="is_deleted = false", + ), + # Maps Django's clickwrap_index + Index("packet_agreement_mapping_packet_idx", "packet_id"), + # Maps Django's agreement_index + Index("packet_agreement_mapping_agreement_idx", "agreement_id"), + ) + + +# --------------------------------------------------------------------------- +# agreement_version (Django: ClickwrapAgreementVersion) +# Immutable content snapshot for an agreement. html_content / pdf_document +# are GCS object paths (same storage pattern as Django FileField). +# --------------------------------------------------------------------------- + + +class AgreementVersion(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "agreement_version" + + agreement_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("agreement.id"), nullable=False + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + name_slug: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[str | None] = mapped_column( + String(20), + nullable=True, + comment="AgreementVersionStatus: DRAFT | PUBLISHED | PAST_PUBLISHED", + ) + source: Mapped[str] = mapped_column( + String(10), + nullable=False, + default=AgreementVersionSource.EDITOR, + server_default=AgreementVersionSource.EDITOR, + comment="AgreementVersionSource: EDIT | EDITOR | UPLOAD", + ) + # GCS object paths — equivalent to Django FileField column values + html_content: Mapped[str | None] = mapped_column( + String(1000), nullable=True, comment="GCS object path for the HTML version file" + ) + pdf_document: Mapped[str | None] = mapped_column( + String(1000), nullable=True, comment="GCS object path for the PDF version file" + ) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + sub_version_number: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + is_current: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + public_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + unique=True, + default=uuid.uuid4, + ) + modified_by_org_user_at: Mapped[datetime | None] = mapped_column(nullable=True) + published_at: Mapped[datetime | None] = mapped_column(nullable=True) + # Raw bigint — references OrganizationUser which lives in Django, not here + published_by_org_user_id: Mapped[int | None] = mapped_column( + BigInteger, nullable=True + ) + is_re_acceptance_required: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + + __table_args__ = ( + # Maps Django's unique_current_version_per_agreement + UniqueConstraint( + "agreement_id", + name="agreement_version_unique_current_per_agreement", + postgresql_where="is_current = true AND is_deleted = false", + ), + # Maps Django's unique_full_version_number_per_clickwrap_agreement + UniqueConstraint( + "version_number", + "sub_version_number", + "agreement_id", + name="agreement_version_unique_version_number", + postgresql_where="is_deleted = false", + ), + # Maps Django's unique_status_equals_published_per_agreement + UniqueConstraint( + "agreement_id", + name="agreement_version_unique_published_per_agreement", + postgresql_where="status = 'PUBLISHED' AND is_deleted = false", + ), + # Maps Django's unique_status_equals_draft_per_agreement + UniqueConstraint( + "agreement_id", + name="agreement_version_unique_draft_per_agreement", + postgresql_where="status = 'DRAFT' AND is_deleted = false", + ), + # Maps Django's cw_agg_ver_name_slug_gin_index + Index("agreement_version_name_slug_gin_idx", "name_slug", postgresql_using="gin"), + ) + + +# --------------------------------------------------------------------------- +# legal_hub (Django: ClickwrapLegalHub) +# Curated collection of agreements surfaced as a hosted page. +# `is_default` renamed from Django's `default` (Python/SQL reserved word). +# --------------------------------------------------------------------------- + + +class LegalHub(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "legal_hub" + + name: Mapped[str] = mapped_column(String(100), nullable=False) + url_slug: Mapped[str] = mapped_column(String(100), nullable=False) + is_default: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="false", + comment="Renamed from Django's `default` field (reserved word)", + ) + + __table_args__ = ( + # Maps Django's lh_slug_unique_per_workspace + UniqueConstraint( + "workspace_id", + "url_slug", + name="legal_hub_slug_unique_per_workspace", + postgresql_where="is_deleted = false", + ), + # Maps Django's lh_name_unique_per_workspace (Lower(F("name"))) + # Note: Django uses case-insensitive Lower() here. The migration must + # manually create a functional index on lower(name) — autogenerate + # will produce a plain unique constraint which should be replaced with: + # CREATE UNIQUE INDEX ... ON legal_hub (lower(name), workspace_id) + # WHERE is_deleted = false + UniqueConstraint( + "workspace_id", + "name", + name="legal_hub_name_unique_per_workspace", + postgresql_where="is_deleted = false", + ), + # Maps Django's lh_one_default_per_workspace + UniqueConstraint( + "workspace_id", + "is_default", + name="legal_hub_one_default_per_workspace", + postgresql_where="is_deleted = false AND is_default = true", + ), + # Maps Django's legal_hub_name_index + Index("legal_hub_name_idx", "name"), + # Maps Django's legal_hub_url_slug_index + Index("legal_hub_url_slug_idx", "url_slug"), + ) + + +# --------------------------------------------------------------------------- +# legal_hub_agreement_mapping (Django: ClickwrapLegalHubAgreementMapping) +# Ordered list of agreements within a legal hub. +# `display_order` renamed from Django's `order` (reserved SQL word). +# --------------------------------------------------------------------------- + + +class LegalHubAgreementMapping(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "legal_hub_agreement_mapping" + + legal_hub_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("legal_hub.id"), nullable=False + ) + agreement_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("agreement.id"), nullable=False + ) + display_order: Mapped[int | None] = mapped_column( + Integer, + nullable=True, + comment="Renamed from Django's `order` field (reserved SQL word)", + ) + + __table_args__ = ( + # Maps Django's legal_hub_agreement_mapping_unique + UniqueConstraint( + "agreement_id", + "legal_hub_id", + name="legal_hub_agreement_mapping_unique", + postgresql_where="is_deleted = false", + ), + # Maps Django's lh_ag_mapping_lh_index + Index("legal_hub_agreement_mapping_hub_idx", "legal_hub_id"), + # Maps Django's lh_ag_mapping_agreement_index + Index("legal_hub_agreement_mapping_agreement_idx", "agreement_id"), + ) + + +# --------------------------------------------------------------------------- +# legal_hub_custom_url_mapping (Django: ClickwrapLegalHubAgreementCustomURLMapping) +# Custom URI routing for individual agreements within a legal hub. +# --------------------------------------------------------------------------- + + +class LegalHubCustomUrlMapping(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "legal_hub_custom_url_mapping" + + custom_uri: Mapped[str] = mapped_column(String(100), nullable=False) + legal_hub_agreement_mapping_id: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("legal_hub_agreement_mapping.id"), nullable=True + ) + + __table_args__ = ( + # Maps Django's lh_custom_url_unique_per_agreement + UniqueConstraint( + "custom_uri", + "legal_hub_agreement_mapping_id", + name="legal_hub_custom_url_unique_per_agreement", + postgresql_where="is_deleted = false", + ), + # Maps Django's lh_custom_url_index + Index("legal_hub_custom_url_mapping_uri_idx", "custom_uri"), + ) diff --git a/app/db/postgres.py b/app/db/postgres.py index e69de29..f299064 100644 --- a/app/db/postgres.py +++ b/app/db/postgres.py @@ -0,0 +1,85 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, func +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from app.core.config import settings + +# --------------------------------------------------------------------------- +# Engine + session factory +# --------------------------------------------------------------------------- + +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEPLOYMENT_ENV == "DEV", + pool_pre_ping=True, +) + +AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=engine, + expire_on_commit=False, + autoflush=False, + autocommit=False, +) + + +# --------------------------------------------------------------------------- +# Declarative base — all ORM models must inherit from this +# --------------------------------------------------------------------------- + + +class Base(DeclarativeBase): + pass + + +# --------------------------------------------------------------------------- +# RuntimeBaseModel +# Every control-plane table inherits from this. +# workspace_id is a raw bigint — no FK to Django's CompanyProfile. +# created_by_org_user_id / updated_by_org_user_id are raw bigints — no FK. +# --------------------------------------------------------------------------- + + +class RuntimeBaseModel(Base): + __abstract__ = True + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + + workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + + created_by_org_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + updated_by_org_user_id: Mapped[int | None] = mapped_column( + BigInteger, nullable=True + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + +# --------------------------------------------------------------------------- +# SoftDeleteMixin +# Composed onto admin-managed entities that support soft delete. +# Not used on append-only / immutable tables. +# --------------------------------------------------------------------------- + + +class SoftDeleteMixin: + is_deleted: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + deleted_by_org_user_id: Mapped[int | None] = mapped_column( + BigInteger, nullable=True + ) diff --git a/pyproject.toml b/pyproject.toml index c8f61bd..828b7f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,11 @@ dependencies = [ # Structured JSON logging (needed from first deployment for GCP Log Explorer) "python-json-logger>=3.2.0", + + # Database — Postgres ORM + async driver + migrations + "SQLAlchemy[asyncio]==2.0.48", + "asyncpg==0.31.0", + "alembic==1.18.4", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 1727a52..1269111 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,20 @@ version = 1 revision = 3 requires-python = "==3.12.*" +[[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" @@ -58,6 +72,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] +[[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/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -90,7 +120,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.139.0" +version = "0.138.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -99,9 +129,27 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/a9/9f8f7e00195c29836e9bf58bbbaf579e29878b8a67851efff93d9b6d4eb7/fastapi-0.138.2.tar.gz", hash = "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8", size = 420423, upload-time = "2026-06-29T12:44:12.556Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, ] [[package]] @@ -195,6 +243,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, ] +[[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/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + [[package]] name = "mypy" version = "2.1.0" @@ -412,6 +491,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.48" +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/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -430,10 +534,13 @@ name = "tars" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "alembic" }, + { name = "asyncpg" }, { name = "fastapi" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-json-logger" }, + { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, ] @@ -448,10 +555,13 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = "==1.18.4" }, + { name = "asyncpg", specifier = "==0.31.0" }, { name = "fastapi", specifier = ">=0.128.0" }, { name = "pydantic", specifier = ">=2.11.0" }, { name = "pydantic-settings", specifier = ">=2.10.0" }, { name = "python-json-logger", specifier = ">=3.2.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.48" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" }, ] @@ -466,11 +576,11 @@ dev = [ [[package]] name = "typing-extensions" -version = "4.16.0" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +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/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, + { 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]] From 735d92e6a786e84c36c134166c2ec4006bddd46b Mon Sep 17 00:00:00 2001 From: Aditya Raj Date: Tue, 7 Jul 2026 16:00:05 +0530 Subject: [PATCH 5/8] added migrations --- alembic.ini | 11 - alembic/env.py | 5 + ...2694ab1_create_initial_clickwrap_models.py | 284 ++++++++++++++++++ app/db/models.py | 148 +++++---- 4 files changed, 358 insertions(+), 90 deletions(-) create mode 100644 alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py diff --git a/alembic.ini b/alembic.ini index 3d85bbd..645b3f0 100644 --- a/alembic.ini +++ b/alembic.ini @@ -19,17 +19,6 @@ truncate_slug_length = 40 # single source of truth. If you need to pass a URL on the CLI you can # use: alembic -x sqlalchemy.url="postgresql+asyncpg://..." upgrade head -[post_write_hooks] -# Run black + ruff after generating a new revision file. -# Requires black and ruff to be installed (already in [dependency-groups.dev]). -hooks = black, ruff -black.type = console_scripts -black.entrypoint = black -black.options = REVISION_SCRIPT_FILENAME -ruff.type = console_scripts -ruff.entrypoint = ruff -ruff.options = check --fix REVISION_SCRIPT_FILENAME - [loggers] keys = root, sqlalchemy, alembic diff --git a/alembic/env.py b/alembic/env.py index f8333a9..8f7ca89 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -11,11 +11,16 @@ import asyncio import logging +import sys from logging.config import fileConfig +from pathlib import Path from alembic import context from sqlalchemy.ext.asyncio import create_async_engine +# Add project root so `app` is importable when alembic is run from repo root. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + # --------------------------------------------------------------------------- # app imports — must be resolvable from the project root # --------------------------------------------------------------------------- diff --git a/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py b/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py new file mode 100644 index 0000000..86e223e --- /dev/null +++ b/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py @@ -0,0 +1,284 @@ +"""create_initial_clickwrap_models + +Revision ID: afc3f2694ab1 +Revises: +Create Date: 2026-07-07 10:13:39.988101+00:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'afc3f2694ab1' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # pg_trgm required for GIN trigram indexes on name_slug (packet, agreement_version). + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('agreement', + sa.Column('url_slug', sa.String(length=100), nullable=True), + sa.Column('header_code', sa.Text(), nullable=True, comment='Custom HTML injected into the agreement header'), + sa.Column('footer_code', sa.Text(), nullable=True, comment='Custom HTML injected into the agreement footer'), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + 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.PrimaryKeyConstraint('id') + ) + op.create_index('agreement_url_slug_unique_per_workspace', 'agreement', ['workspace_id', 'url_slug'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_table('domain_setting', + sa.Column('custom_domain', sa.String(length=50), nullable=True), + sa.Column('custom_domain_status', sa.String(length=20), server_default='DRAFT', nullable=False), + sa.Column('default_domain', sa.String(length=50), nullable=False, comment='Set by the service from settings.CLUSTER_ID on creation. Format: clickwrap.{cluster_id}.spotdraft.com'), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + 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.PrimaryKeyConstraint('id') + ) + op.create_index('domain_setting_custom_domain_idx', 'domain_setting', ['custom_domain'], unique=False) + op.create_index('domain_setting_custom_domain_unique_per_workspace', 'domain_setting', ['workspace_id', 'custom_domain'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_index('domain_setting_workspace_idx', 'domain_setting', ['workspace_id'], unique=False) + op.create_table('legal_hub', + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('url_slug', sa.String(length=100), nullable=False), + sa.Column('is_default', sa.Boolean(), server_default='false', nullable=False, comment="Renamed from Django's `default` field (reserved word)"), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + 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.PrimaryKeyConstraint('id') + ) + op.create_index('legal_hub_name_idx', 'legal_hub', ['name'], unique=False) + # Django uses Lower(F("name")) — case-insensitive unique per workspace. + op.execute( + """ + CREATE UNIQUE INDEX legal_hub_name_unique_per_workspace + ON legal_hub (lower(name), workspace_id) + WHERE is_deleted = false + """ + ) + op.create_index('legal_hub_one_default_per_workspace', 'legal_hub', ['workspace_id'], unique=True, postgresql_where=sa.text('is_deleted = false AND is_default = true')) + op.create_index('legal_hub_slug_unique_per_workspace', 'legal_hub', ['workspace_id', 'url_slug'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_index('legal_hub_url_slug_idx', 'legal_hub', ['url_slug'], unique=False) + op.create_table('packet_settings', + sa.Column('agreement_ui_type', sa.String(length=50), server_default='SINGLE_CHECKBOX', nullable=False, comment='Maps ClickwrapType — controls SDK presentation'), + sa.Column('clickwrap_texts', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('whitelisted_domains', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False), + sa.Column('show_audit_click_status', sa.Boolean(), server_default='false', nullable=False), + sa.Column('send_executed_audit_email', sa.Boolean(), server_default='false', nullable=False), + sa.Column('allow_all_domains', sa.Boolean(), server_default='false', nullable=False), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + 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.PrimaryKeyConstraint('id') + ) + op.create_table('whitelabel_config', + sa.Column('company_logo', sa.Text(), nullable=False, comment='GCS object path for company logo'), + sa.Column('logo_redirect_url', sa.Text(), nullable=True), + sa.Column('custom_styles', postgresql.JSON(astext_type=sa.Text()), nullable=False, comment='Brand CSS overrides e.g. primary_color'), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('header_text', sa.String(length=100), nullable=True), + sa.Column('brand_name', sa.String(length=100), nullable=True), + sa.Column('add_footer', sa.Boolean(), server_default='true', nullable=False), + sa.Column('display_dropdown_and_download', sa.Boolean(), server_default='true', nullable=False), + sa.Column('display_published_agreements', sa.Boolean(), server_default='true', nullable=False), + sa.Column('favicon_icon', sa.Text(), nullable=True, comment='GCS object path for favicon (.ico)'), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + 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.PrimaryKeyConstraint('id') + ) + op.create_index('whitelabel_config_unique_per_workspace', 'whitelabel_config', ['workspace_id', 'is_active'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_index('whitelabel_config_workspace_is_active_idx', 'whitelabel_config', ['workspace_id', 'is_active'], unique=False) + op.create_table('agreement_version', + sa.Column('agreement_id', sa.BigInteger(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('name_slug', sa.String(length=100), nullable=False), + sa.Column('status', sa.String(length=20), nullable=True, comment='AgreementVersionStatus: DRAFT | PUBLISHED | PAST_PUBLISHED'), + sa.Column('source', sa.String(length=10), server_default='EDITOR', nullable=False, comment='AgreementVersionSource: EDIT | EDITOR | UPLOAD'), + sa.Column('html_content', sa.String(length=1000), nullable=True, comment='GCS object path for the HTML version file'), + sa.Column('pdf_document', sa.String(length=1000), nullable=True, comment='GCS object path for the PDF version file'), + sa.Column('version_number', sa.Integer(), nullable=False), + sa.Column('sub_version_number', sa.Integer(), server_default='0', nullable=False), + sa.Column('is_current', sa.Boolean(), nullable=True), + sa.Column('public_id', sa.UUID(), nullable=False), + sa.Column('modified_by_org_user_at', sa.DateTime(), nullable=True), + sa.Column('published_at', sa.DateTime(), nullable=True), + sa.Column('published_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('is_re_acceptance_required', sa.Boolean(), server_default='false', nullable=False), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + 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(['agreement_id'], ['agreement.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('public_id') + ) + op.create_index('agreement_version_name_slug_gin_idx', 'agreement_version', ['name_slug'], unique=False, postgresql_using='gin', postgresql_ops={'name_slug': 'gin_trgm_ops'}) + op.create_index('agreement_version_unique_draft_per_agreement', 'agreement_version', ['agreement_id'], unique=True, postgresql_where=sa.text("status = 'DRAFT' AND is_deleted = false")) + op.create_index('agreement_version_unique_published_per_agreement', 'agreement_version', ['agreement_id'], unique=True, postgresql_where=sa.text("status = 'PUBLISHED' AND is_deleted = false")) + op.create_index('agreement_version_unique_version_number', 'agreement_version', ['version_number', 'sub_version_number', 'agreement_id'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_table('legal_hub_agreement_mapping', + sa.Column('legal_hub_id', sa.BigInteger(), nullable=False), + sa.Column('agreement_id', sa.BigInteger(), nullable=False), + sa.Column('display_order', sa.Integer(), nullable=True, comment="Renamed from Django's `order` field (reserved SQL word)"), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + 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(['agreement_id'], ['agreement.id'], ), + sa.ForeignKeyConstraint(['legal_hub_id'], ['legal_hub.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('legal_hub_agreement_mapping_agreement_idx', 'legal_hub_agreement_mapping', ['agreement_id'], unique=False) + op.create_index('legal_hub_agreement_mapping_hub_idx', 'legal_hub_agreement_mapping', ['legal_hub_id'], unique=False) + op.create_index('legal_hub_agreement_mapping_unique', 'legal_hub_agreement_mapping', ['agreement_id', 'legal_hub_id'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_table('packet', + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('name_slug', sa.String(length=100), nullable=False), + sa.Column('description', sa.String(length=500), nullable=True), + sa.Column('public_id', sa.UUID(), nullable=False, comment='Stable public identifier exposed to SDK callers'), + sa.Column('packet_settings_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_at', sa.DateTime(), nullable=True, comment='Last time an org user explicitly saved changes'), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + 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(['packet_settings_id'], ['packet_settings.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('packet_settings_id'), + sa.UniqueConstraint('public_id') + ) + op.create_index('packet_name_slug_gin_idx', 'packet', ['name_slug'], unique=False, postgresql_using='gin', postgresql_ops={'name_slug': 'gin_trgm_ops'}) + op.create_index('packet_name_unique_per_workspace', 'packet', ['workspace_id', 'name_slug'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_index('packet_workspace_idx', 'packet', ['workspace_id'], unique=False) + op.create_table('legal_hub_custom_url_mapping', + sa.Column('custom_uri', sa.String(length=100), nullable=False), + sa.Column('legal_hub_agreement_mapping_id', sa.BigInteger(), nullable=True), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + 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(['legal_hub_agreement_mapping_id'], ['legal_hub_agreement_mapping.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('legal_hub_custom_url_mapping_uri_idx', 'legal_hub_custom_url_mapping', ['custom_uri'], unique=False) + op.create_index('legal_hub_custom_url_unique_per_agreement', 'legal_hub_custom_url_mapping', ['custom_uri', 'legal_hub_agreement_mapping_id'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_table('packet_agreement_mapping', + sa.Column('packet_id', sa.BigInteger(), nullable=False), + sa.Column('agreement_id', sa.BigInteger(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + 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(['agreement_id'], ['agreement.id'], ), + sa.ForeignKeyConstraint(['packet_id'], ['packet.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('packet_agreement_mapping_agreement_idx', 'packet_agreement_mapping', ['agreement_id'], unique=False) + op.create_index('packet_agreement_mapping_packet_idx', 'packet_agreement_mapping', ['packet_id'], unique=False) + op.create_index('packet_agreement_mapping_unique', 'packet_agreement_mapping', ['packet_id', 'agreement_id'], unique=True, postgresql_where=sa.text('is_deleted = false')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('packet_agreement_mapping_unique', table_name='packet_agreement_mapping', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('packet_agreement_mapping_packet_idx', table_name='packet_agreement_mapping') + op.drop_index('packet_agreement_mapping_agreement_idx', table_name='packet_agreement_mapping') + op.drop_table('packet_agreement_mapping') + op.drop_index('legal_hub_custom_url_unique_per_agreement', table_name='legal_hub_custom_url_mapping', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('legal_hub_custom_url_mapping_uri_idx', table_name='legal_hub_custom_url_mapping') + op.drop_table('legal_hub_custom_url_mapping') + op.drop_index('packet_workspace_idx', table_name='packet') + op.drop_index('packet_name_unique_per_workspace', table_name='packet', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('packet_name_slug_gin_idx', table_name='packet', postgresql_using='gin', postgresql_ops={'name_slug': 'gin_trgm_ops'}) + op.drop_table('packet') + op.drop_index('legal_hub_agreement_mapping_unique', table_name='legal_hub_agreement_mapping', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('legal_hub_agreement_mapping_hub_idx', table_name='legal_hub_agreement_mapping') + op.drop_index('legal_hub_agreement_mapping_agreement_idx', table_name='legal_hub_agreement_mapping') + op.drop_table('legal_hub_agreement_mapping') + op.drop_index('agreement_version_unique_version_number', table_name='agreement_version', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('agreement_version_unique_published_per_agreement', table_name='agreement_version', postgresql_where=sa.text("status = 'PUBLISHED' AND is_deleted = false")) + op.drop_index('agreement_version_unique_draft_per_agreement', table_name='agreement_version', postgresql_where=sa.text("status = 'DRAFT' AND is_deleted = false")) + op.drop_index('agreement_version_name_slug_gin_idx', table_name='agreement_version', postgresql_using='gin', postgresql_ops={'name_slug': 'gin_trgm_ops'}) + op.drop_table('agreement_version') + op.drop_index('whitelabel_config_workspace_is_active_idx', table_name='whitelabel_config') + op.drop_index('whitelabel_config_unique_per_workspace', table_name='whitelabel_config', postgresql_where=sa.text('is_deleted = false')) + op.drop_table('whitelabel_config') + op.drop_table('packet_settings') + op.drop_index('legal_hub_url_slug_idx', table_name='legal_hub') + op.drop_index('legal_hub_slug_unique_per_workspace', table_name='legal_hub', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('legal_hub_one_default_per_workspace', table_name='legal_hub', postgresql_where=sa.text('is_deleted = false AND is_default = true')) + op.drop_index('legal_hub_name_unique_per_workspace', table_name='legal_hub') + op.drop_index('legal_hub_name_idx', table_name='legal_hub') + op.drop_table('legal_hub') + op.drop_index('domain_setting_workspace_idx', table_name='domain_setting') + op.drop_index('domain_setting_custom_domain_unique_per_workspace', table_name='domain_setting', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('domain_setting_custom_domain_idx', table_name='domain_setting') + op.drop_table('domain_setting') + op.drop_index('agreement_url_slug_unique_per_workspace', table_name='agreement', postgresql_where=sa.text('is_deleted = false')) + op.drop_table('agreement') + op.execute("DROP EXTENSION IF EXISTS pg_trgm") + # ### end Alembic commands ### diff --git a/app/db/models.py b/app/db/models.py index 2938332..c0d49dd 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -37,7 +37,8 @@ Integer, String, Text, - UniqueConstraint, + func, + text, ) from sqlalchemy.dialects.postgresql import ARRAY, JSON, UUID from sqlalchemy.orm import Mapped, mapped_column @@ -102,9 +103,8 @@ class Packet(SoftDeleteMixin, RuntimeBaseModel): default=uuid.uuid4, comment="Stable public identifier exposed to SDK callers", ) - # FK to packet_settings — same table, DB-level constraint packet_settings_id: Mapped[int] = mapped_column( - BigInteger, ForeignKey("packet_settings.id"), nullable=False + BigInteger, ForeignKey("packet_settings.id"), nullable=False, unique=True ) updated_by_org_user_at: Mapped[datetime | None] = mapped_column( nullable=True, @@ -112,17 +112,20 @@ class Packet(SoftDeleteMixin, RuntimeBaseModel): ) __table_args__ = ( - # Maps Django's clickwrap_name_unique_per_workspace - UniqueConstraint( + Index( + "packet_name_unique_per_workspace", "workspace_id", "name_slug", - name="packet_name_unique_per_workspace", - postgresql_where="is_deleted = false", + unique=True, + postgresql_where=text("is_deleted = false"), ), - # Maps Django's clickwrap_workspace_index Index("packet_workspace_idx", "workspace_id"), - # Maps Django's clickwrap_name_slug_gin_index - Index("packet_name_slug_gin_idx", "name_slug", postgresql_using="gin"), + Index( + "packet_name_slug_gin_idx", + "name_slug", + postgresql_using="gin", + postgresql_ops={"name_slug": "gin_trgm_ops"}, + ), ) @@ -153,11 +156,12 @@ class DomainSetting(SoftDeleteMixin, RuntimeBaseModel): __table_args__ = ( # Maps Django's custom_domain_unique_per_workspace - UniqueConstraint( + Index( + "domain_setting_custom_domain_unique_per_workspace", "workspace_id", "custom_domain", - name="domain_setting_custom_domain_unique_per_workspace", - postgresql_where="is_deleted = false", + unique=True, + postgresql_where=text("is_deleted = false"), ), # Maps Django's cwd_index_workspace_index Index("domain_setting_workspace_idx", "workspace_id"), @@ -176,7 +180,6 @@ class DomainSetting(SoftDeleteMixin, RuntimeBaseModel): class WhitelabelConfig(SoftDeleteMixin, RuntimeBaseModel): __tablename__ = "whitelabel_config" - # GCS object path — equivalent to Django FileField column value company_logo: Mapped[str] = mapped_column( Text, nullable=False, comment="GCS object path for company logo" ) @@ -200,21 +203,18 @@ class WhitelabelConfig(SoftDeleteMixin, RuntimeBaseModel): display_published_agreements: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="true" ) - # GCS object path — equivalent to Django FileField column value - # FaviconIconValidator moves to Pydantic schema / use case favicon_icon: Mapped[str | None] = mapped_column( Text, nullable=True, comment="GCS object path for favicon (.ico)" ) __table_args__ = ( - # Maps Django's unique_clickwrap_agreement_config_per_workspace - UniqueConstraint( + Index( + "whitelabel_config_unique_per_workspace", "workspace_id", "is_active", - name="whitelabel_config_unique_per_workspace", - postgresql_where="is_deleted = false", + unique=True, + postgresql_where=text("is_deleted = false"), ), - # Maps Django's unnamed (tenant_workspace, is_active) index Index("whitelabel_config_workspace_is_active_idx", "workspace_id", "is_active"), ) @@ -238,14 +238,13 @@ class Agreement(SoftDeleteMixin, RuntimeBaseModel): ) __table_args__ = ( - # Maps Django's unique_url_slug_per_workspace - UniqueConstraint( + Index( + "agreement_url_slug_unique_per_workspace", "workspace_id", "url_slug", - name="agreement_url_slug_unique_per_workspace", - postgresql_where="is_deleted = false", + unique=True, + postgresql_where=text("is_deleted = false"), ), - # No explicit indexes in Django for this table — none added. ) @@ -266,16 +265,14 @@ class PacketAgreementMapping(SoftDeleteMixin, RuntimeBaseModel): ) __table_args__ = ( - # Maps Django's clickwrap_agreement_mapping_unique - UniqueConstraint( + Index( + "packet_agreement_mapping_unique", "packet_id", "agreement_id", - name="packet_agreement_mapping_unique", - postgresql_where="is_deleted = false", + unique=True, + postgresql_where=text("is_deleted = false"), ), - # Maps Django's clickwrap_index Index("packet_agreement_mapping_packet_idx", "packet_id"), - # Maps Django's agreement_index Index("packet_agreement_mapping_agreement_idx", "agreement_id"), ) @@ -336,41 +333,42 @@ class AgreementVersion(SoftDeleteMixin, RuntimeBaseModel): ) __table_args__ = ( - # Maps Django's unique_current_version_per_agreement - UniqueConstraint( - "agreement_id", - name="agreement_version_unique_current_per_agreement", - postgresql_where="is_current = true AND is_deleted = false", - ), - # Maps Django's unique_full_version_number_per_clickwrap_agreement - UniqueConstraint( + Index( + "agreement_version_unique_version_number", "version_number", "sub_version_number", "agreement_id", - name="agreement_version_unique_version_number", - postgresql_where="is_deleted = false", + unique=True, + postgresql_where=text("is_deleted = false"), ), # Maps Django's unique_status_equals_published_per_agreement - UniqueConstraint( + Index( + "agreement_version_unique_published_per_agreement", "agreement_id", - name="agreement_version_unique_published_per_agreement", - postgresql_where="status = 'PUBLISHED' AND is_deleted = false", + unique=True, + postgresql_where=text("status = 'PUBLISHED' AND is_deleted = false"), ), # Maps Django's unique_status_equals_draft_per_agreement - UniqueConstraint( + Index( + "agreement_version_unique_draft_per_agreement", "agreement_id", - name="agreement_version_unique_draft_per_agreement", - postgresql_where="status = 'DRAFT' AND is_deleted = false", + unique=True, + postgresql_where=text("status = 'DRAFT' AND is_deleted = false"), ), # Maps Django's cw_agg_ver_name_slug_gin_index - Index("agreement_version_name_slug_gin_idx", "name_slug", postgresql_using="gin"), + # Requires pg_trgm extension (enabled in migration) + Index( + "agreement_version_name_slug_gin_idx", + "name_slug", + postgresql_using="gin", + postgresql_ops={"name_slug": "gin_trgm_ops"}, + ), ) # --------------------------------------------------------------------------- # legal_hub (Django: ClickwrapLegalHub) # Curated collection of agreements surfaced as a hosted page. -# `is_default` renamed from Django's `default` (Python/SQL reserved word). # --------------------------------------------------------------------------- @@ -388,31 +386,27 @@ class LegalHub(SoftDeleteMixin, RuntimeBaseModel): ) __table_args__ = ( - # Maps Django's lh_slug_unique_per_workspace - UniqueConstraint( + Index( + "legal_hub_slug_unique_per_workspace", "workspace_id", "url_slug", - name="legal_hub_slug_unique_per_workspace", - postgresql_where="is_deleted = false", + unique=True, + postgresql_where=text("is_deleted = false"), ), # Maps Django's lh_name_unique_per_workspace (Lower(F("name"))) - # Note: Django uses case-insensitive Lower() here. The migration must - # manually create a functional index on lower(name) — autogenerate - # will produce a plain unique constraint which should be replaced with: - # CREATE UNIQUE INDEX ... ON legal_hub (lower(name), workspace_id) - # WHERE is_deleted = false - UniqueConstraint( + Index( + "legal_hub_name_unique_per_workspace", + func.lower(name), "workspace_id", - "name", - name="legal_hub_name_unique_per_workspace", - postgresql_where="is_deleted = false", + unique=True, + postgresql_where=text("is_deleted = false"), ), # Maps Django's lh_one_default_per_workspace - UniqueConstraint( + Index( + "legal_hub_one_default_per_workspace", "workspace_id", - "is_default", - name="legal_hub_one_default_per_workspace", - postgresql_where="is_deleted = false AND is_default = true", + unique=True, + postgresql_where=text("is_deleted = false AND is_default = true"), ), # Maps Django's legal_hub_name_index Index("legal_hub_name_idx", "name"), @@ -424,7 +418,6 @@ class LegalHub(SoftDeleteMixin, RuntimeBaseModel): # --------------------------------------------------------------------------- # legal_hub_agreement_mapping (Django: ClickwrapLegalHubAgreementMapping) # Ordered list of agreements within a legal hub. -# `display_order` renamed from Django's `order` (reserved SQL word). # --------------------------------------------------------------------------- @@ -444,16 +437,14 @@ class LegalHubAgreementMapping(SoftDeleteMixin, RuntimeBaseModel): ) __table_args__ = ( - # Maps Django's legal_hub_agreement_mapping_unique - UniqueConstraint( + Index( + "legal_hub_agreement_mapping_unique", "agreement_id", "legal_hub_id", - name="legal_hub_agreement_mapping_unique", - postgresql_where="is_deleted = false", + unique=True, + postgresql_where=text("is_deleted = false"), ), - # Maps Django's lh_ag_mapping_lh_index Index("legal_hub_agreement_mapping_hub_idx", "legal_hub_id"), - # Maps Django's lh_ag_mapping_agreement_index Index("legal_hub_agreement_mapping_agreement_idx", "agreement_id"), ) @@ -473,13 +464,12 @@ class LegalHubCustomUrlMapping(SoftDeleteMixin, RuntimeBaseModel): ) __table_args__ = ( - # Maps Django's lh_custom_url_unique_per_agreement - UniqueConstraint( + Index( + "legal_hub_custom_url_unique_per_agreement", "custom_uri", "legal_hub_agreement_mapping_id", - name="legal_hub_custom_url_unique_per_agreement", - postgresql_where="is_deleted = false", + unique=True, + postgresql_where=text("is_deleted = false"), ), - # Maps Django's lh_custom_url_index Index("legal_hub_custom_url_mapping_uri_idx", "custom_uri"), ) From 832a665e5b3f87f2c7cafe163cca7a5a6faef4c1 Mon Sep 17 00:00:00 2001 From: Aditya Raj Date: Wed, 8 Jul 2026 16:39:26 +0530 Subject: [PATCH 6/8] updated readme file --- README.md | 118 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 99 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0855cf5..39d00cc 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,15 @@ tars/ │ │ ├── config.py # pydantic-settings Settings (env vars) │ │ └── log_config.py # GCP-compatible JSON logging (GcpJsonFormatter) │ ├── db/ -│ │ ├── postgres.py # PostgreSQL session/engine (stub) +│ │ ├── postgres.py # SQLAlchemy engine, session factory, base ORM classes +│ │ ├── models.py # All ORM model definitions (centralised) +│ │ ├── enums.py # Shared DB enums │ │ └── firestore.py # Firestore client (stub) │ ├── agreement/ # Agreement bounded context -│ │ ├── models.py │ │ ├── data/postgres/ # Repository layer │ │ ├── domain/use_cases/ # Business logic │ │ └── presentation/ # Routes / request handlers │ ├── clickwrap/ # Clickwrap bounded context -│ │ ├── models.py │ │ ├── data/postgres/ │ │ ├── domain/use_cases/ │ │ └── presentation/ @@ -31,16 +31,19 @@ tars/ │ │ ├── domain/use_cases/ │ │ └── presentation/ │ └── legal_hub/ # Legal hub bounded context -│ ├── models.py │ ├── data/postgres/ │ ├── domain/use_cases/ │ └── presentation/ +├── alembic/ # DB migrations +│ ├── env.py +│ └── versions/ ├── tests/ │ ├── test_health.py │ ├── agreement/ │ ├── clickwrap/ │ ├── consent/ │ └── legal_hub/ +├── alembic.ini ├── Dockerfile ├── pyproject.toml ├── ruff.toml @@ -54,32 +57,106 @@ tars/ ### Prerequisites -- [UV](https://docs.astral.sh/uv/) — install once with: +- **[uv](https://docs.astral.sh/uv/)** — Python package manager. Install once with: ```bash curl -LsSf https://astral.sh/uv/install.sh | sh ``` -### Install & Run +- **PostgreSQL** — the service uses Postgres for control-plane data. A local instance is required. With Homebrew: + ```bash + brew install postgresql@16 + brew services start postgresql@16 + ``` + +### 1. Install dependencies ```bash -# 1. Install Python 3.12 and project dependencies uv python install 3.12 uv sync +``` -# 2. Copy and configure environment variables +### 2. Configure environment variables + +```bash cp .env.example .env -# Edit .env as needed +``` + +Open `.env` and set at minimum: + +```bash +DEPLOYMENT_ENV=DEV +LOG_LEVEL=INFO +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/tars +``` + +See the [Environment Variables](#environment-variables) section for all options. + +### 3. Create the database -# 3. Start the dev server (hot-reload) +```bash +psql -U postgres -c "CREATE DATABASE tars;" +``` + +### 4. Apply migrations + +```bash +uv run alembic upgrade head +``` + +### 5. Start the dev server + +```bash uv run uvicorn app.main:app --reload ``` -The service will be available at: +The service is available at: - `http://127.0.0.1:8000/ht` — health check (Kubernetes liveness/readiness probe) - `http://127.0.0.1:8000/docs` — Swagger UI --- +## Database Migrations + +Migrations are managed with [Alembic](https://alembic.sqlalchemy.org/). The `DATABASE_URL` is read from `.env` (or the environment) — it is not set in `alembic.ini`. + +### Apply all pending migrations + +```bash +uv run alembic upgrade head +``` + +### Roll back the latest migration + +```bash +uv run alembic downgrade -1 +``` + +### Check current migration state + +```bash +uv run alembic current +``` + +### View migration history + +```bash +uv run alembic history --verbose +``` + +### Create a new migration + +After adding or modifying an ORM model in `app/db/models.py`, autogenerate a migration: + +```bash +uv run alembic revision --autogenerate -m "short_description_of_change" +``` + +Always review the generated file in `alembic/versions/` before committing — autogenerate can miss certain changes (e.g. check constraints, custom indexes, server defaults). + +> **Note:** Migration PRs must be kept separate from feature code changes. See the team PR guidelines. + +--- + ## Running Tests ```bash @@ -99,30 +176,35 @@ uv run ruff format . # format ## Environment Variables +All variables are read by `app/core/config.py` via pydantic-settings. Set them in `.env` locally or as real environment variables in deployed environments. Real environment variables take precedence over `.env`. + | Variable | Default | Description | |---|---|---| -| `DEPLOYMENT_ENV` | `DEV` | Deployment environment label (`DEV`, `QA`, `PROD`) | -| `LOG_LEVEL` | `INFO` | Python log level | +| `DEPLOYMENT_ENV` | `DEV` | Deployment environment label (`DEV`, `QA`, `PROD`). Enables SQL echo logging when set to `DEV`. | +| `LOG_LEVEL` | `INFO` | Python log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | | `API_V1_STR` | `/api/v1` | API version prefix | +| `DATABASE_URL` | `postgresql+asyncpg://postgres:postgres@localhost:5432/tars` | Async DSN for Postgres. Format: `postgresql+asyncpg://user:password@host:port/dbname` | +| `CLUSTER_ID` | `IN` | Cluster identifier. Used as the subdomain component in `domain_setting.default_domain` (e.g. `clickwrap.IN.spotdraft.com`) | --- ## Architecture -Tars follows a **domain-driven, layered architecture** consistent with other SpotDraft FastAPI services (oogway, tigress): +Tars follows a **domain-driven, layered architecture** consistent with other SpotDraft FastAPI services: ``` presentation/ ← FastAPI routes, request/response handling domain/ ← Business logic (use cases, domain models) use_cases/ data/ ← Persistence adapters (Postgres or Firestore) -models.py ← SQLAlchemy / Pydantic domain models ``` Each bounded context (`agreement`, `clickwrap`, `consent`, `legal_hub`) owns its full stack of layers independently. Cross-cutting concerns (config, logging, DB sessions) live in `app/core/` and `app/db/`. +**ORM models** are centralised in `app/db/models.py` rather than per-module files — this avoids circular imports and keeps the migration target (`Base.metadata`) in one place. + **Storage:** -- `agreement`, `clickwrap`, `legal_hub` — PostgreSQL via `app/db/postgres.py` +- `agreement`, `clickwrap`, `legal_hub` — PostgreSQL via async SQLAlchemy (`app/db/postgres.py`) - `consent` — Firestore via `app/db/firestore.py` --- @@ -134,9 +216,7 @@ docker build -t tars . docker run -p 8000:8000 --env-file .env tars ``` -Base image: `python:3.12-slim` (standard across SpotDraft FastAPI services). - -> **Chainguard migration:** django-rest-api uses `ghcr.io/spotdraft/python-builder` (backed by `cgr.dev/chainguard-private/python:3.12-dev` with SafeDep PMG). Adopting this for Tars is tracked as a follow-up once the platform team publishes a runner image. +Base image: `python:3.12-slim`. --- From 64e9c49d94bd68a0b6751b1b8fed1c6dda79d7aa Mon Sep 17 00:00:00 2001 From: Aditya Raj Date: Thu, 9 Jul 2026 13:20:52 +0530 Subject: [PATCH 7/8] added timezone=true for datefields --- .../202677_afc3f2694ab1_create_initial_clickwrap_models.py | 6 +++--- app/db/models.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py b/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py index 86e223e..2eba2ea 100644 --- a/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py +++ b/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py @@ -138,8 +138,8 @@ def upgrade() -> None: sa.Column('sub_version_number', sa.Integer(), server_default='0', nullable=False), sa.Column('is_current', sa.Boolean(), nullable=True), sa.Column('public_id', sa.UUID(), nullable=False), - sa.Column('modified_by_org_user_at', sa.DateTime(), nullable=True), - sa.Column('published_at', sa.DateTime(), nullable=True), + sa.Column('modified_by_org_user_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('published_at', sa.DateTime(timezone=True), nullable=True), sa.Column('published_by_org_user_id', sa.BigInteger(), nullable=True), sa.Column('is_re_acceptance_required', sa.Boolean(), server_default='false', nullable=False), sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), @@ -185,7 +185,7 @@ def upgrade() -> None: sa.Column('description', sa.String(length=500), nullable=True), sa.Column('public_id', sa.UUID(), nullable=False, comment='Stable public identifier exposed to SDK callers'), sa.Column('packet_settings_id', sa.BigInteger(), nullable=False), - sa.Column('updated_by_org_user_at', sa.DateTime(), nullable=True, comment='Last time an org user explicitly saved changes'), + sa.Column('updated_by_org_user_at', sa.DateTime(timezone=True), nullable=True, comment='Last time an org user explicitly saved changes'), sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), diff --git a/app/db/models.py b/app/db/models.py index c0d49dd..d22e5af 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -32,6 +32,7 @@ from sqlalchemy import ( BigInteger, Boolean, + DateTime, ForeignKey, Index, Integer, @@ -107,6 +108,7 @@ class Packet(SoftDeleteMixin, RuntimeBaseModel): BigInteger, ForeignKey("packet_settings.id"), nullable=False, unique=True ) updated_by_org_user_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, comment="Last time an org user explicitly saved changes", ) @@ -322,8 +324,8 @@ class AgreementVersion(SoftDeleteMixin, RuntimeBaseModel): unique=True, default=uuid.uuid4, ) - modified_by_org_user_at: Mapped[datetime | None] = mapped_column(nullable=True) - published_at: Mapped[datetime | None] = mapped_column(nullable=True) + modified_by_org_user_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # Raw bigint — references OrganizationUser which lives in Django, not here published_by_org_user_id: Mapped[int | None] = mapped_column( BigInteger, nullable=True From f26911225e20e735294ba36d21908bd815c160f9 Mon Sep 17 00:00:00 2001 From: Aditya Raj Date: Thu, 9 Jul 2026 14:16:12 +0530 Subject: [PATCH 8/8] resolved comments --- .../202677_afc3f2694ab1_create_initial_clickwrap_models.py | 4 ++-- app/db/models.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py b/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py index 2eba2ea..06616e1 100644 --- a/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py +++ b/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py @@ -124,7 +124,7 @@ def upgrade() -> None: sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.PrimaryKeyConstraint('id') ) - op.create_index('whitelabel_config_unique_per_workspace', 'whitelabel_config', ['workspace_id', 'is_active'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_index('whitelabel_config_unique_per_workspace', 'whitelabel_config', ['workspace_id'], unique=True, postgresql_where=sa.text('is_active = true AND is_deleted = false')) op.create_index('whitelabel_config_workspace_is_active_idx', 'whitelabel_config', ['workspace_id', 'is_active'], unique=False) op.create_table('agreement_version', sa.Column('agreement_id', sa.BigInteger(), nullable=False), @@ -265,7 +265,7 @@ def downgrade() -> None: op.drop_index('agreement_version_name_slug_gin_idx', table_name='agreement_version', postgresql_using='gin', postgresql_ops={'name_slug': 'gin_trgm_ops'}) op.drop_table('agreement_version') op.drop_index('whitelabel_config_workspace_is_active_idx', table_name='whitelabel_config') - op.drop_index('whitelabel_config_unique_per_workspace', table_name='whitelabel_config', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('whitelabel_config_unique_per_workspace', table_name='whitelabel_config', postgresql_where=sa.text('is_active = true AND is_deleted = false')) op.drop_table('whitelabel_config') op.drop_table('packet_settings') op.drop_index('legal_hub_url_slug_idx', table_name='legal_hub') diff --git a/app/db/models.py b/app/db/models.py index d22e5af..2ac05d1 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -213,9 +213,8 @@ class WhitelabelConfig(SoftDeleteMixin, RuntimeBaseModel): Index( "whitelabel_config_unique_per_workspace", "workspace_id", - "is_active", unique=True, - postgresql_where=text("is_deleted = false"), + postgresql_where=text("is_active = true AND is_deleted = false"), ), Index("whitelabel_config_workspace_is_active_idx", "workspace_id", "is_active"), )