diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml deleted file mode 100644 index 3652522..0000000 --- a/.github/workflows/ci-quality.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: CI Qualité - -on: - pull_request: - branches: [main] - -jobs: - quality: - name: Ruff + Mypy + Tests - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: astral-sh/setup-uv@v7 - with: - enable-caching: true - - - name: Installer les dépendances - run: uv sync --frozen --group dev - - - name: Ruff lint - run: uv run ruff check . - - - name: Ruff format - run: uv run ruff format --check . - - - name: Mypy - run: uv run mypy src/ - - - name: Tests - run: uv run pytest diff --git a/.github/workflows/ci-template.yml b/.github/workflows/ci-template.yml new file mode 100644 index 0000000..b5004a4 --- /dev/null +++ b/.github/workflows/ci-template.yml @@ -0,0 +1,31 @@ +name: CI Template + +on: + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + bake-smoke: + name: bake (3 combos, smoke) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: { enable-caching: true } + - run: uv sync --group dev + - run: uv run pytest -m unit -v + - run: uv run ruff check hooks/ tests/ + + bake-integration: + name: bake + ruff + custom lints + pytest unit on baked project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: { enable-caching: true } + - run: uv sync --group dev + - run: uv run pytest -m integration -v diff --git a/.github/workflows/soma-quality.yml b/.github/workflows/soma-quality.yml deleted file mode 100644 index 1ab72d7..0000000 --- a/.github/workflows/soma-quality.yml +++ /dev/null @@ -1,53 +0,0 @@ -# Workflow SOMA Quality — Conventional Commits -# Valide que tous les commits de la PR respectent la convention Conventional Commits. -# Documentation : https://www.conventionalcommits.org/fr/v1.0.0/ - -name: SOMA Quality - -on: - pull_request: - types: [opened, synchronize, reopened] - branches: [main] - -jobs: - conventional-commits: - name: Conventional Commits - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Valider les messages de commit - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.sha }} - run: | - PATTERN="^(feat|fix|docs|refactor|test|ci|chore|perf)(\(.+\))?(!)?: .{1,100}" - FAILED=0 - - while IFS= read -r commit_msg; do - if printf '%s\n' "$commit_msg" | grep -qE "^(Merge|Revert|fixup!|squash!)"; then - continue - fi - - if ! printf '%s\n' "$commit_msg" | grep -qE "$PATTERN"; then - echo "::error::Message invalide : \"$commit_msg\"" - echo "::error::Format attendu : [scope]: " - echo "::error::Types autorisés : feat | fix | docs | refactor | test | ci | chore | perf" - echo "::error::Exemples : feat: ajout auth OAuth | fix(api): timeout /users | docs: README" - FAILED=1 - fi - done < <(git log "$BASE_SHA".."$HEAD_SHA" --format="%s") - - if [ "$FAILED" -eq 1 ]; then - echo "" - echo "❌ Des commits ne respectent pas la convention SOMA." - echo " Référence : https://www.conventionalcommits.org/fr/v1.0.0/" - echo " Hook local : bash /chemin/vers/doc-soma-manifest/templates/install-hooks.sh" - exit 1 - fi - - echo "✓ Tous les commits respectent la convention Conventional Commits." diff --git a/.gitignore b/.gitignore index 31a7e68..91dda32 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,10 @@ Thumbs.db .env .env.* !.env.example + +# Cookiecutter local bake outputs +/tmp-bake/ +/.bake/ +# Locally baked test projects (any baked project living at the repo root). +# Keep them out of version control so devs can experiment freely. +/toto/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 903c827..c84ce13 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,11 @@ +# Pre-commit for the template repo itself. +# The heavy guardrails (custom lints, ty, pytest, mutmut) live inside the +# generated project at {{cookiecutter.project_slug}}/.pre-commit-config.yaml. +# Cookiecutter-templated files contain Jinja syntax that breaks YAML/TOML +# parsers, so we exclude that whole directory from syntactic checks. + +exclude: '^\{\{cookiecutter\.project_slug\}\}/' + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 @@ -8,6 +16,7 @@ repos: - id: check-toml - id: detect-private-key - id: check-added-large-files + - id: check-merge-conflict - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.9.0 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 1c67888..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,79 +0,0 @@ -# [Nom du projet] - -> Ce fichier fournit le contexte du projet aux outils IA. -> Gardez-le à jour à chaque changement significatif d'architecture ou de stack. -> Pour les outils qui ne le lisent pas automatiquement, copiez-collez son contenu en début de session. - -## Projet - -**Description :** [Décrivez en 2-3 phrases ce que fait le projet et sa valeur métier] - -**Stack technique :** -- Langage : Python 3.13 -- Framework principal : [ex: FastAPI 0.115] -- Base de données : [ex: PostgreSQL 17] -- Autres dépendances clés : [ex: Redis, Celery, SQLAlchemy] - -**Structure du projet :** -``` -[Coller ici l'arbre du projet — ex: src/, tests/, docs/] -``` - -## Conventions obligatoires - -### Git -- **Conventional Commits** : `[scope]: ` -- Types autorisés : `feat`, `fix`, `docs`, `refactor`, `test`, `ci`, `chore`, `perf` -- **Jamais de commit direct sur `main`** -- Nommage des branches : `feature/`, `fix/`, `hotfix/`, `docs/`, `refactor/` -- PRs obligatoires, reviewées par quelqu'un d'autre que l'auteur - -### Code -- Fonctions de **25 lignes maximum** -- **Pas de duplication** de code -- Nommage **explicite** (pas d'abréviations obscures) -- Linter : `ruff` + type checker : `mypy` (configurés dans `pyproject.toml`) - -### Tests -- Tests écrits avant ou en même temps que le code (TDD si possible) -- CI bloque le merge si les tests échouent - -### Sécurité -- **Aucun secret dans le code** (tokens, mots de passe, clés API) -- Variables d'environnement pour toute configuration sensible -- Fichier `.env` dans `.gitignore` - -## Ce qu'il ne faut surtout pas faire - -- Commiter directement sur `main` -- Utiliser `git push --force` -- Contourner le linter (`# noqa`, `# type: ignore`) sans justification dans un commentaire -- Merger sans que les tests CI passent -- Inclure des données réelles (clients, production) dans les exemples ou les tests -- Coller des secrets dans les prompts IA - -## Informations pratiques - -**Installer les dépendances :** -```bash -uv sync -``` - -**Lancer le projet en local :** -```bash -[commandes pour démarrer le projet] -``` - -**Lancer les tests :** -```bash -uv run pytest -``` - -**Linter + format :** -```bash -uv run ruff check . -uv run ruff format . -``` - -**Documentation technique :** [lien si disponible] -**CI/CD :** GitHub Actions — qualité (ruff + mypy + pytest) + sécurité (bandit + pip-audit + gitleaks) + conventional commits diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ccb0d12 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# dev-soma-template — Repo IA Rules + +> This file governs **AI editing of the template itself**. +> The generated projects ship their own `CLAUDE.md` inside `{{cookiecutter.project_slug}}/CLAUDE.md`. + +## What this repo is + +A **Cookiecutter** that generates SOMA Python services. Output = a project with FastAPI + Clean Architecture + observability + Helm + multi-layer guardrails (Claude skills, hooks, pre-commit, CI). + +The design rationale lives in the git history (commit messages are the source of truth for "why"). Each generated project ships its own `docs/architecture.md` and `docs/adr/`. + +## Repo layout + +``` +. +├── cookiecutter.json # template variables +├── hooks/ # pre/post_gen_project Python hooks +├── tests/ # pytest-cookies bake tests of the template +└── {{cookiecutter.project_slug}}/ # the generated project (see its own CLAUDE.md) +``` + +## Hard rules for AI editors + +1. **Two roots, two pyprojects, two CLAUDE.md** — never confuse them: + - root `pyproject.toml` = template tooling (cookiecutter, pytest, pytest-cookies, ruff) + - `{{cookiecutter.project_slug}}/pyproject.toml` = generated project deps (FastAPI, SQLAlchemy, etc.) +2. **Never** edit `{{cookiecutter.project_slug}}/` files to fix a generated-project bug discovered downstream — fix at the template level so the next bake inherits the fix. +3. **Cookiecutter templating syntax** uses `{{ cookiecutter. }}`. Be careful when editing JSON/YAML files: Jinja braces inside JSON strings need to remain valid Jinja, not break JSON parsing. +4. **Bake tests are the contract** — adding a feature without updating `tests/test_template_bake.py` is a regression. +5. **Conventional commits** required. Branches: `feat/*`, `fix/*`, `docs/*`, `refactor/*`, `chore/*`. PRs must pass all CI checks. + +## Editing workflow + +1. Edit either the template machinery (root) **or** the generated project skeleton (`{{cookiecutter.project_slug}}/`) — rarely both in the same change. +2. Run bake tests locally: `uv run pytest`. +3. Write the commit message so it captures the *why* of the change (it's the long-term decision record). + +## Commands + +```bash +uv sync # install template tooling +uv run pytest # run bake tests +uv run cookiecutter . --no-input -o /tmp # bake with default values for manual inspection +uv run ruff check hooks/ tests/ # only on the template machinery — see note below +uv run ruff format hooks/ tests/ +``` + +**Note on ruff paths**: `uv run ruff check .` fails because ruff walks into `{{cookiecutter.project_slug}}/` and tries to parse the Jinja-templated `pyproject.toml` as a nested config. Always pass explicit paths (`hooks/`, `tests/`) at the template root. Pre-commit handles this transparently via its `exclude` regex. diff --git a/README.md b/README.md index dbe42cf..b83af40 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,91 @@ -# [NOM DU PROJET] +# dev-soma-template -> Template SOMA — remplacer ce fichier par la documentation du projet. +A **Cookiecutter** template that generates state-of-the-art SOMA Python services. -## Description +What you get out-of-the-box: -[Description du projet en 2-3 phrases] +- **FastAPI 0.115+** + **SQLAlchemy 2.0 async** + **asyncpg** + **Alembic** +- **Clean Architecture** (Uncle Bob strict): `domain` / `application` / `infrastructure` / `presentation` +- **uv** + **ruff** (extended ruleset) + **ty** (Astral type checker) + **mutmut** (mutation testing) +- **OpenTelemetry** auto-switching between console and OTLP exporters +- **structlog** with technical (JSON) and human-readable formats +- **Devcontainer** with full local stack (Postgres + Jaeger + OTel collector) +- **Helm chart** with multi-environment values, schema validation, migrations job +- **Multi-layer guardrails** preventing convention drift: Claude Code skills + hooks, pre-commit, git hooks, CI -## Stack +## Generate a new project -- Python 3.13 -- [Framework principal] -- [Autres dépendances clés] - -## Installation +### Interactive (recommended for first use) ```bash -# Installer les dépendances -uv sync +# Install cookiecutter (one-time, globally or via pipx/uvx) +uvx cookiecutter gh:soma-smart/dev-soma-template -# Installer le hook de commit (conventional commits) -bash /chemin/vers/doc-soma-manifest/templates/install-hooks.sh +# Or from a local clone +git clone git@github.com:soma-smart/dev-soma-template.git +uvx cookiecutter ./dev-soma-template ``` -## Lancer le projet +### Non-interactive (CI, scripts, repeat bakes) + +`cookiecutter` accepts overrides as positional `key=value` arguments +**after** the template path. Combine with `--no-input` to skip every +prompt and `-o ` to control the output directory: ```bash -[commandes pour démarrer le projet] +uvx cookiecutter ./dev-soma-template --no-input \ + -o /tmp/out \ + project_name="Billing API" \ + database=postgres \ + include_helm=yes ``` -## Tests +Any variable not passed on the CLI uses its default from `cookiecutter.json`. +For larger configs, point `--config-file ` at a YAML file with a +`default_context:` mapping. + +### Variables + +You will be prompted for: + +| Variable | Default | Description | +| ---------------------- | --------------------- | --------------------------------------------------- | +| `project_name` | `My SOMA Service` | Human-readable project name | +| `project_slug` | derived from name | Folder + repo slug (`kebab-case`) | +| `package_name` | derived from slug | Importable Python package (`snake_case`) | +| `project_description` | | One-liner | +| `author_name` | `SOMA` | | +| `author_email` | `team@soma-smart.com` | | +| `python_version` | `3.13` | `3.13` or `3.14` | +| `database` | `postgres` | `postgres` (full) or `sqlite` (light) | +| `include_helm` | `yes` | Ship a Helm chart | +| `include_otel` | `yes` | Wire OpenTelemetry instrumentations | +| `license` | `proprietary` | `proprietary`, `MIT`, `Apache-2.0` | + +After generation, the post-hook automatically: + +1. `git init` + initial commit +2. `uv sync --group dev` +3. `cp .env.example .env` +4. `pre-commit install` (commit-msg + pre-push hooks) + +## Develop on the template itself ```bash -uv run pytest +uv sync # template tooling +uv run pytest # bake tests (pytest-cookies) +uv run cookiecutter . --no-input -o /tmp/out # smoke-bake with defaults +uv run ruff check . && uv run ruff format . ``` -## Linter +Bake with custom values for manual inspection — same positional +`key=value` syntax as the user-facing flow above: ```bash -uv run ruff check . -uv run ruff format . +uv run cookiecutter . --no-input -o /tmp/out \ + project_name="Smoke Bake" \ + database=sqlite \ + include_helm=no ``` + +See [`CLAUDE.md`](CLAUDE.md) for AI-assisted editing rules. diff --git a/cookiecutter.json b/cookiecutter.json new file mode 100644 index 0000000..f5d0cf5 --- /dev/null +++ b/cookiecutter.json @@ -0,0 +1,30 @@ +{ + "project_name": "My SOMA Service", + "project_slug": "{{ cookiecutter.project_name|lower|replace(' ', '-')|replace('_', '-') }}", + "package_name": "{{ cookiecutter.project_slug|replace('-', '_') }}", + "project_description": "Short one-line description of the service.", + "author_name": "SOMA", + "author_email": "team@soma-smart.com", + "python_version": ["3.13", "3.14"], + "database": ["postgres", "sqlite"], + "include_helm": ["yes", "no"], + "include_otel": ["yes", "no"], + "frontend_sdk": ["none", "typescript"], + "swagger_auth_scheme": ["http_bearer", "oauth2_auth_code"], + "license": ["proprietary", "MIT", "Apache-2.0"], + "__prompts__": { + "project_name": "Nom du projet (lisible, ex: 'Billing API')", + "project_slug": "Auto-généré depuis le nom — utilisé pour le dossier et l'URL du repo", + "package_name": "Auto-généré depuis le slug — ce que tu importes en Python", + "project_description": "Une phrase qui dit ce que le service fait (apparaît dans le README et l'OpenAPI)", + "author_name": "Toi ou ton équipe", + "author_email": "Email de contact", + "python_version": "Version de Python visée", + "database": "Moteur de base de données. SOMA utilise sqlite dans le devcontainer (léger, pas de serveur à lancer en local) et postgres pour le déploiement via Helm.", + "include_helm": "Générer la config pour rendre l'application disponible à SOMA", + "include_otel": "Activer le tracing + les métriques (OpenTelemetry) pour voir ce qui se passe en prod (requêtes lentes, erreurs externes, etc.) ?", + "frontend_sdk": "Si un frontend (React, Vue, etc.) va consommer ton API, mets 'typescript' : une lib cliente typée est générée et publiée à chaque push. 'none' si pas de frontend JS.", + "swagger_auth_scheme": "Comment un dev se connecte quand il essaie l'API depuis Swagger UI : 'http_bearer' = il colle un token à la main (marche avec n'importe quel fournisseur, accepte aussi des tokens de dev) ; 'oauth2_auth_code' = bouton 'Login with ' en un clic (marche uniquement avec l'IdP que tu auras enregistré — ex : Azure AD pour soma-smart.com). Le code supporte les deux ; ce choix ne change que l'expérience par défaut dans Swagger.", + "license": "Qui a le droit d'utiliser le code : 'proprietary' = SOMA uniquement, pas de partage externe (défaut, choix sûr pour un service interne) ; 'MIT' = très permissive, tout le monde peut utiliser/modifier/revendre sans contrainte (pour une lib open source simple) ; 'Apache-2.0' = permissive comme MIT mais avec une clause explicite sur les brevets (à choisir pour de l'open source qui touche à des algos / brevets)." + } +} diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py new file mode 100644 index 0000000..ba271d8 --- /dev/null +++ b/hooks/post_gen_project.py @@ -0,0 +1,196 @@ +"""Cookiecutter post-generation hook. + +Runs after the project is generated to: + 1. Conditionally remove optional directories (helm/, openapi exporter, etc.) + 2. Copy .env.example -> .env + 3. Initialize git + 4. uv sync --group dev + 5. Install pre-commit hooks (commit-msg + pre-push) + +Failures are reported but do not abort the generation: the project files are +already on disk and the user can fix tooling issues afterwards. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path.cwd() +INCLUDE_HELM = "{{ cookiecutter.include_helm }}" == "yes" +INCLUDE_OTEL = "{{ cookiecutter.include_otel }}" == "yes" +FRONTEND_SDK = "{{ cookiecutter.frontend_sdk }}" +DATABASE = "{{ cookiecutter.database }}" +PACKAGE_NAME = "{{ cookiecutter.package_name }}" +SWAGGER_AUTH_SCHEME = "{{ cookiecutter.swagger_auth_scheme }}" + + +def info(message: str) -> None: + sys.stdout.write(f" -> {message}\n") + + +def warn(message: str) -> None: + sys.stderr.write(f" ! {message}\n") + + +def remove_path(relative: str) -> None: + target = PROJECT_ROOT / relative + if target.is_dir(): + shutil.rmtree(target) + info(f"removed {relative}/") + elif target.is_file(): + target.unlink() + info(f"removed {relative}") + + +def run(*command: str) -> bool: + try: + subprocess.run(command, check=True, cwd=PROJECT_ROOT) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + warn(f"{' '.join(command)} failed: {exc}") + return False + return True + + +def git_commit_step(message: str, *paths: str) -> None: + """Stage the listed paths and commit them, no-op when nothing changed. + + Bypasses any installed hooks (-n): bootstrap commits must not be gated by + linters that haven't even been run on the user's machine yet. + """ + if not (PROJECT_ROOT / ".git").exists(): + return + for path in paths: + if (PROJECT_ROOT / path).exists(): + run("git", "add", "--", path) + status = subprocess.run( + ["git", "diff", "--cached", "--quiet"], + cwd=PROJECT_ROOT, + check=False, + ) + if status.returncode == 0: + return + run("git", "commit", "-n", "-m", message) + + +def remove_optional_components() -> None: + if not INCLUDE_HELM: + remove_path("helm") + if not INCLUDE_OTEL: + info("OTel disabled: kept the observability module so you can wire it later.") + if FRONTEND_SDK == "none": + remove_path("scripts/export_openapi.py") + remove_path(".github/workflows/sdk-typescript.yml") + if SWAGGER_AUTH_SCHEME != "oauth2_auth_code": + remove_path(".devcontainer/keycloak") + if DATABASE == "sqlite": + info("database=sqlite: docker-compose Postgres service is still provided as opt-in.") + + +def setup_env_file() -> None: + src = PROJECT_ROOT / ".env.example" + dst = PROJECT_ROOT / ".env" + if src.exists() and not dst.exists(): + shutil.copy(src, dst) + info("created .env from .env.example") + + +def git_init() -> None: + if (PROJECT_ROOT / ".git").exists(): + return + if not run("git", "init", "--initial-branch=main"): + return + run("git", "add", ".") + run("git", "commit", "-m", "feat: initial commit from soma template v2") + + +def uv_sync() -> None: + if not run("uv", "sync", "--group", "dev"): + warn("uv sync failed. Install uv and run 'uv sync --group dev' manually.") + return + git_commit_step("chore: lock dependencies", "uv.lock") + + +def install_pre_commit() -> None: + if not run("uv", "run", "pre-commit", "install", "--install-hooks"): + warn("pre-commit install (default) failed.") + return + run( + "uv", + "run", + "pre-commit", + "install", + "--hook-type", + "commit-msg", + "--hook-type", + "pre-push", + ) + + +def apply_initial_migration() -> None: + """Run ``alembic upgrade head`` for SQLite only. + + SQLite is local-file, deterministic, completes in <1s. For Postgres the + DB URL points at the in-network ``postgres`` service that is unreachable + from the host running cookiecutter; the devcontainer's ``postStartCommand`` + (``just migrate``) applies the schema once the service is healthy. + """ + if DATABASE != "sqlite": + info("alembic upgrade head deferred to devcontainer postStart (just migrate).") + return + + env = os.environ.copy() + env_file = PROJECT_ROOT / ".env" + if env_file.exists(): + for raw_line in env_file.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + env[key.strip()] = value.strip() + + try: + subprocess.run( + ["uv", "run", "alembic", "upgrade", "head"], + cwd=PROJECT_ROOT, + env=env, + check=True, + capture_output=True, + timeout=15, + ) + info("applied initial migration") + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as exc: + warn(f"alembic upgrade head skipped: {exc}") + + +def print_next_steps() -> None: + sys.stdout.write( + "\n" + "Generated successfully.\n" + "\n" + "Next steps:\n" + " cd " + PROJECT_ROOT.name + "\n" + " code . # then 'Reopen in Container'\n" + " uv run uvicorn " + PACKAGE_NAME + ".presentation.api.app:app --reload\n" + " uv run pytest -m unit\n" + "\n" + "Read CLAUDE.md and docs/architecture.md before adding your first feature.\n" + ) + + +def main() -> None: + info("running post-generation hook") + remove_optional_components() + setup_env_file() + git_init() + uv_sync() + install_pre_commit() + apply_initial_migration() + print_next_steps() + + +if __name__ == "__main__": + main() diff --git a/hooks/pre_gen_project.py b/hooks/pre_gen_project.py new file mode 100644 index 0000000..cf01101 --- /dev/null +++ b/hooks/pre_gen_project.py @@ -0,0 +1,51 @@ +"""Cookiecutter pre-generation hook. + +Validates user inputs before the project is generated. Aborts with a clear +error message when constraints are violated. +""" + +from __future__ import annotations + +import re +import sys + +PACKAGE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") +PROJECT_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$") +SUPPORTED_PYTHON = {"3.13", "3.14"} + +PACKAGE_NAME = "{{ cookiecutter.package_name }}" +PROJECT_SLUG = "{{ cookiecutter.project_slug }}" +PYTHON_VERSION = "{{ cookiecutter.python_version }}" +DATABASE = "{{ cookiecutter.database }}" + + +def fail(message: str) -> None: + sys.stderr.write(f"\nERROR: {message}\n") + sys.exit(1) + + +def main() -> None: + if not PACKAGE_NAME_RE.match(PACKAGE_NAME): + fail( + f"package_name '{PACKAGE_NAME}' is invalid. Must match {PACKAGE_NAME_RE.pattern} " + "(lowercase, start with a letter, only letters/digits/underscores)." + ) + + if not PROJECT_SLUG_RE.match(PROJECT_SLUG): + fail( + f"project_slug '{PROJECT_SLUG}' is invalid. Must match {PROJECT_SLUG_RE.pattern} " + "(lowercase, start with a letter, only letters/digits/hyphens)." + ) + + if PYTHON_VERSION not in SUPPORTED_PYTHON: + fail(f"python_version '{PYTHON_VERSION}' is not supported. Choose one of: {sorted(SUPPORTED_PYTHON)}.") + + if DATABASE == "none": + fail( + "database=none is not supported in template v2: the reference feature in examples/ " + "requires persistence. Choose 'postgres' or 'sqlite' instead." + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index de02689..3ecaa36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,28 +1,45 @@ [project] -name = "REPLACE_ME" -version = "0.1.0" +name = "dev-soma-template" +version = "2.0.0-dev" +description = "Cookiecutter template for state-of-the-art SOMA Python services." +readme = "README.md" requires-python = ">=3.13" -dependencies = [] +license = { text = "Proprietary" } +dependencies = [ + "cookiecutter>=2.6", +] [dependency-groups] dev = [ - "bandit>=1.8", - "mypy>=1.15", - "pip-audit>=2.9", "pytest>=8.0", + "pytest-cookies>=0.7", "ruff>=0.9", ] [tool.ruff] line-length = 120 target-version = "py313" +# The cookiecutter content tree contains Jinja-templated source files that are +# not parseable until baked. Exclude the whole tree from ruff in the template +# repo. The generated project ships its own ruff config that lints the baked +# files normally. +extend-exclude = [ + "{{cookiecutter.project_slug}}", + "{{cookiecutter.project_slug}}/**", +] +force-exclude = true [tool.ruff.lint] -select = ["E", "F", "I", "B", "S"] +select = ["E", "F", "I", "B", "S", "UP", "RUF", "PTH"] -[tool.mypy] -strict = true -ignore_missing_imports = true +[tool.ruff.lint.per-file-ignores] +"hooks/*.py" = ["S603", "S607"] +"tests/*.py" = ["S101", "S603", "S607"] [tool.pytest.ini_options] testpaths = ["tests"] +addopts = "-ra --strict-markers" +markers = [ + "unit: bake-time unit tests on the template", + "integration: tests that bake and run tooling on the produced project", +] diff --git a/src/.gitkeep b/tests/__init__.py similarity index 100% rename from src/.gitkeep rename to tests/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py index b48496a..390ae1e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,2 +1 @@ -# conftest.py — configuration pytest commune -# Ajouter les fixtures partagées ici +"""Shared pytest configuration for the template's bake tests.""" diff --git a/tests/test_template_bake.py b/tests/test_template_bake.py new file mode 100644 index 0000000..d32e9bf --- /dev/null +++ b/tests/test_template_bake.py @@ -0,0 +1,225 @@ +"""Bake the cookiecutter and verify the produced project is sane. + +Smoke checks (always-on): + - bake completes for the default combo + - expected files are present + - produced pyproject is valid TOML + +Combo checks (also always-on, fast): + - postgres + helm + otel + - sqlite + no-helm + otel + - sqlite + no-helm + no-otel + +Tooling checks on the baked project (marked `integration` — opt-in via +``pytest -m integration``): runs ``ruff check``, the four custom lints, and +``pytest -m unit`` against the freshly baked tree. Slower because each combo +re-installs deps. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tomllib +from pathlib import Path + +import pytest + +EXPECTED_TOP_FILES = ( + "pyproject.toml", + "README.md", + "CLAUDE.md", + "LICENSE", + "SECURITY.md", + ".env.example", + "Dockerfile", + ".pre-commit-config.yaml", + "alembic.ini", + ".github/CODEOWNERS", +) + + +@pytest.mark.unit +def test_given_default_inputs_when_baking_then_creates_expected_layout(cookies) -> None: # type: ignore[no-untyped-def] + # GIVEN + extra_context = {"project_name": "Acme Service"} + + # WHEN + result = cookies.bake(extra_context=extra_context) + + # THEN + assert result.exit_code == 0, f"bake failed: {result.exception}" + assert result.exception is None + project = Path(result.project_path) + for name in EXPECTED_TOP_FILES: + assert (project / name).exists(), f"missing top-level {name}" + assert (project / "src" / "acme_service" / "__init__.py").is_file() + assert (project / "src" / "acme_service" / "presentation" / "api" / "app.py").is_file() + assert (project / "docs" / "runbooks" / "backups-and-restore.md").is_file() + assert (project / "docs" / "runbooks" / "disaster-recovery.md").is_file() + assert (project / "docs" / "runbooks" / "verification-log.md").is_file() + skills_dir = project / ".claude" / "skills" + assert skills_dir.is_dir() + expected_skills = { + "onboarding-soma", + "tdd-workflow", + "building-a-feature", + "writing-domain-code", + "database-and-migrations", + "adding-auth", + "event-sourcing-pattern", + "writing-a-helm-change", + "reviewing-a-pr-soma-style", + } + actual_skills = {p.name for p in skills_dir.iterdir() if p.is_dir()} + assert actual_skills == expected_skills, ( + f"skill inventory drift: missing={expected_skills - actual_skills}, " + f"unexpected={actual_skills - expected_skills}" + ) + for skill_name in expected_skills: + assert (skills_dir / skill_name / "SKILL.md").is_file(), f"missing SKILL.md in {skill_name}" + agents_dir = project / ".claude" / "agents" + if agents_dir.exists(): + agent_files = [p for p in agents_dir.iterdir() if p.is_file() and p.suffix == ".md"] + assert agent_files == [], f"no sub-agents should ship with the template, found: {agent_files}" + assert (project / "helm" / "acme-service" / "Chart.yaml").is_file() + assert (project / ".devcontainer" / "devcontainer.json").is_file() + post_up = project / ".devcontainer" / "post-up.sh" + assert post_up.is_file(), "missing .devcontainer/post-up.sh" + assert post_up.stat().st_mode & 0o111, "post-up.sh must be executable" + + +@pytest.mark.unit +def test_given_baked_project_when_reading_pyproject_then_has_correct_name(cookies) -> None: # type: ignore[no-untyped-def] + # GIVEN + result = cookies.bake(extra_context={"project_name": "Acme Service"}) + pyproject_path = Path(result.project_path) / "pyproject.toml" + + # WHEN + pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) + + # THEN + assert pyproject["project"]["name"] == "acme-service" + assert pyproject["project"]["requires-python"].startswith(">=") + + +@pytest.mark.unit +def test_given_database_none_forced_via_extra_context_when_baking_then_aborts(cookies) -> None: # type: ignore[no-untyped-def] + # GIVEN: 'none' is no longer offered in the interactive menu, but the + # pre_gen hook still guards against programmatic overrides (CI scripts, + # cookiecutter --extra-context ...). + + # WHEN + result = cookies.bake(extra_context={"project_name": "Bad", "database": "none"}) + + # THEN + assert result.exit_code != 0 + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("license_value", "marker"), + [ + ("proprietary", "All rights reserved"), + ("MIT", "MIT License"), + ("Apache-2.0", "Apache License"), + ], +) +def test_given_license_choice_when_baking_then_license_file_matches( # type: ignore[no-untyped-def] + cookies, license_value: str, marker: str +) -> None: + # GIVEN / WHEN + safe_name = license_value.replace(".", "").replace("-", "") + result = cookies.bake(extra_context={"project_name": f"Lic {safe_name}", "license": license_value}) + + # THEN + assert result.exit_code == 0, result.exception + license_text = (Path(result.project_path) / "LICENSE").read_text(encoding="utf-8") + assert marker in license_text + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("combo_id", "context"), + [ + ("postgres-helm-otel", {"database": "postgres", "include_helm": "yes", "include_otel": "yes"}), + ("sqlite-no-helm-otel", {"database": "sqlite", "include_helm": "no", "include_otel": "yes"}), + ("sqlite-no-helm-no-otel", {"database": "sqlite", "include_helm": "no", "include_otel": "no"}), + ("ts-sdk", {"database": "postgres", "frontend_sdk": "typescript"}), + ("swagger-oauth2", {"swagger_auth_scheme": "oauth2_auth_code"}), + ], +) +def test_given_combo_when_baking_then_succeeds(cookies, combo_id: str, context: dict[str, str]) -> None: # type: ignore[no-untyped-def] + # GIVEN + extra = {"project_name": f"Combo {combo_id}"} | context + + # WHEN + result = cookies.bake(extra_context=extra) + + # THEN + assert result.exit_code == 0, f"bake failed for {combo_id}: {result.exception}" + project = Path(result.project_path) + assert (project / "pyproject.toml").is_file() + if context.get("include_helm") == "no": + assert not (project / "helm").exists() + elif context.get("include_helm") == "yes": + assert (project / "helm").is_dir() + helm_templates = next(iter((project / "helm").iterdir())) / "templates" + for relay_template in ("relay-deployment.yaml", "relay-service.yaml", "relay-servicemonitor.yaml"): + assert (helm_templates / relay_template).is_file(), f"missing relay template {relay_template}" + assert (helm_templates / "prometheusrule.yaml").is_file(), "missing prometheusrule.yaml" + if context.get("frontend_sdk") == "typescript": + assert (project / "scripts" / "export_openapi.py").is_file() + assert (project / ".github" / "workflows" / "sdk-typescript.yml").is_file() + else: + assert not (project / "scripts" / "export_openapi.py").exists() + assert not (project / ".github" / "workflows" / "sdk-typescript.yml").exists() + env_text = (project / ".env.example").read_text(encoding="utf-8") + if context.get("swagger_auth_scheme") == "oauth2_auth_code": + # Defaults are wired to the local Keycloak shipped in the devcontainer. + assert "SWAGGER_OAUTH2_AUTHORIZATION_URL=http://localhost:8080/realms/dev/" in env_text + assert "AUTH_JWT_ALGORITHM=RS256" in env_text + assert (project / ".devcontainer" / "keycloak" / "realm-export.json").is_file() + else: + # Default flavour keeps the OAuth2 endpoints empty (paste-once UX). + assert "SWAGGER_OAUTH2_AUTHORIZATION_URL=\n" in env_text + assert "AUTH_JWT_ALGORITHM=HS256" in env_text + assert not (project / ".devcontainer" / "keycloak").exists() + + +@pytest.mark.integration +def test_given_default_bake_when_running_quality_pipeline_then_passes(cookies) -> None: # type: ignore[no-untyped-def] + # GIVEN + if shutil.which("uv") is None: + pytest.skip("uv not available") + result = cookies.bake(extra_context={"project_name": "Quality Check"}) + assert result.exit_code == 0 + project = Path(result.project_path) + env = {**os.environ, "DB_URL": "sqlite+aiosqlite:///./local.db"} + + # WHEN + sync = subprocess.run(["uv", "sync", "--group", "dev"], cwd=project, env=env, capture_output=True) + ruff = subprocess.run( + ["uv", "run", "ruff", "check", "src/", "tests/", "scripts/"], + cwd=project, env=env, capture_output=True, + ) + lint_domain = subprocess.run( + ["uv", "run", "python", "scripts/checks/no_third_party_in_domain.py", "src/"], + cwd=project, env=env, capture_output=True, + ) + test_naming = subprocess.run( + ["uv", "run", "python", "scripts/checks/test_naming.py", "tests/"], + cwd=project, env=env, capture_output=True, + ) + pytest_unit = subprocess.run( + ["uv", "run", "pytest", "-m", "unit", "-q"], + cwd=project, env=env, capture_output=True, + ) + + # THEN + assert sync.returncode == 0, sync.stderr.decode() + assert ruff.returncode == 0, ruff.stdout.decode() + assert lint_domain.returncode == 0, lint_domain.stderr.decode() + assert test_naming.returncode == 0, test_naming.stderr.decode() + assert pytest_unit.returncode == 0, pytest_unit.stdout.decode() diff --git a/uv.lock b/uv.lock index 4ed54c0..a58e1d1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,97 +1,105 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] -name = "bandit" -version = "1.9.4" +name = "arrow" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "stevedore" }, + { name = "python-dateutil" }, + { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, ] [[package]] -name = "boolean-py" -version = "5.0" +name = "binaryornot" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/4755b85101f37707c71526a301c1203e413c715a0016ecb592de3d2dcfff/binaryornot-0.6.0.tar.gz", hash = "sha256:cc8d57cfa71d74ff8c28a7726734d53a851d02fad9e3a5581fb807f989f702f0", size = 478718, upload-time = "2026-03-08T16:26:28.804Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, -] - -[[package]] -name = "cachecontrol" -version = "0.14.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "msgpack" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, -] - -[package.optional-dependencies] -filecache = [ - { name = "filelock" }, + { url = "https://files.pythonhosted.org/packages/cd/0c/31cfaa6b56fe23488ecb993bc9fc526c0d84d89607decdf2a10776426c2e/binaryornot-0.6.0-py3-none-any.whl", hash = "sha256:900adfd5e1b821255ba7e63139b0396b14c88b9286e74e03b6f51e0200331337", size = 14185, upload-time = "2026-03-08T16:26:27.466Z" }, ] [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -104,45 +112,56 @@ wheels = [ ] [[package]] -name = "cyclonedx-python-lib" -version = "11.6.0" +name = "cookiecutter" +version = "2.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "license-expression" }, - { name = "packageurl-python" }, - { name = "py-serializable" }, - { name = "sortedcontainers" }, + { name = "arrow" }, + { name = "binaryornot" }, + { name = "click" }, + { name = "jinja2" }, + { name = "python-slugify" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/ed/54ecfa25fc145c58bf4f98090f7b6ffe5188d0759248c57dde44427ea239/cyclonedx_python_lib-11.6.0.tar.gz", hash = "sha256:7fb85a4371fa3a203e5be577ac22b7e9a7157f8b0058b7448731474d6dea7bf0", size = 1408147, upload-time = "2025-12-02T12:28:46.446Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/03/f4c96d8fd4f5e8af0210bf896eb63927f35d3014a8e8f3bf9d2c43ad3332/cookiecutter-2.7.1.tar.gz", hash = "sha256:ca7bb7bc8c6ff441fbf53921b5537668000e38d56e28d763a1b73975c66c6138", size = 142854, upload-time = "2026-03-04T04:06:02.786Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/14/a9/8c855c14b401dc67d20739345295af5afce5e930a69600ab20f6cfa50b5c/cookiecutter-2.7.1-py3-none-any.whl", hash = "sha256:cee50defc1eaa7ad0071ee9b9893b746c1b3201b66bf4d3686d0f127c8ed6cf9", size = 41317, upload-time = "2026-03-04T04:06:01.221Z" }, ] [[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +name = "dev-soma-template" +version = "2.0.0.dev0" +source = { virtual = "." } +dependencies = [ + { name = "cookiecutter" }, ] -[[package]] -name = "filelock" -version = "3.25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cookies" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "cookiecutter", specifier = ">=2.6" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cookies", specifier = ">=0.7" }, + { name = "ruff", specifier = ">=0.9" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] @@ -155,62 +174,15 @@ wheels = [ ] [[package]] -name = "librt" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, -] - -[[package]] -name = "license-expression" -version = "30.4.4" +name = "jinja2" +version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "boolean-py" }, + { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] @@ -225,6 +197,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -234,221 +258,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, -] - -[[package]] -name = "mypy" -version = "1.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, -] - -[[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 = "packageurl-python" -version = "0.17.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, -] - [[package]] name = "packaging" -version = "26.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +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/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { 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.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, -] - -[[package]] -name = "pip" -version = "26.0.1" +name = "pluggy" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" } +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/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" }, + { 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 = "pip-api" -version = "0.0.34" +name = "pygments" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pip" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } +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/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, + { 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 = "pip-audit" -version = "2.10.0" +name = "pytest" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cachecontrol", extra = ["filecache"] }, - { name = "cyclonedx-python-lib" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, { name = "packaging" }, - { name = "pip-api" }, - { name = "pip-requirements-parser" }, - { name = "platformdirs" }, - { name = "requests" }, - { name = "rich" }, - { name = "tomli" }, - { name = "tomli-w" }, + { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/89/0e999b413facab81c33d118f3ac3739fd02c0622ccf7c4e82e37cebd8447/pip_audit-2.10.0.tar.gz", hash = "sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4", size = 53776, upload-time = "2025-12-01T23:42:40.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] -name = "pip-requirements-parser" -version = "32.0.1" +name = "pytest-cookies" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.9.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, + { name = "cookiecutter" }, + { name = "pytest" }, ] - -[[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" } +sdist = { url = "https://files.pythonhosted.org/packages/18/2e/11a3e1abb4bbf10e0af3f194ba4c55600de3fe52417ef3594c18d28ecdbe/pytest-cookies-0.7.0.tar.gz", hash = "sha256:1aaa6b4def8238d0d1709d3d773b423351bfb671c1e3438664d824e0859d6308", size = 8840, upload-time = "2023-03-22T11:07:29.595Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/438af2f3a6c58f81d22c126707ee5d079f653a76961f4fb7d995e526a9c4/pytest_cookies-0.7.0-py3-none-any.whl", hash = "sha256:52770f090d77b16428f6a24a208e6be76addb2e33458035714087b4de49389ea", size = 6386, upload-time = "2023-03-22T11:07:28.068Z" }, ] [[package]] -name = "py-serializable" -version = "2.1.0" +name = "python-dateutil" +version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "defusedxml" }, + { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pyparsing" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.2" +name = "python-slugify" +version = "8.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, + { name = "text-unidecode" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, ] [[package]] @@ -489,7 +376,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -497,144 +384,74 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] name = "rich" -version = "14.3.3" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "ruff" -version = "0.15.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, - { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, - { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, - { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, - { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, - { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, -] - -[[package]] -name = "soma-template" -version = "0.1.0" -source = { virtual = "." } - -[package.dev-dependencies] -dev = [ - { name = "bandit" }, - { name = "mypy" }, - { name = "pip-audit" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] - -[package.metadata.requires-dev] -dev = [ - { name = "bandit", specifier = ">=1.8" }, - { name = "mypy", specifier = ">=1.15" }, - { name = "pip-audit", specifier = ">=2.9" }, - { name = "pytest", specifier = ">=8.0" }, - { name = "ruff", specifier = ">=0.9" }, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - -[[package]] -name = "stevedore" -version = "5.7.0" +version = "0.15.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] -name = "tomli" -version = "2.4.0" +name = "six" +version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] -name = "tomli-w" -version = "1.2.0" +name = "text-unidecode" +version = "1.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, ] [[package]] -name = "typing-extensions" -version = "4.15.0" +name = "tzdata" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] diff --git a/{{cookiecutter.project_slug}}/.claude/hooks/audit-edit.sh b/{{cookiecutter.project_slug}}/.claude/hooks/audit-edit.sh new file mode 100755 index 0000000..913bff3 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/hooks/audit-edit.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Claude PostToolUse(Edit/Write): observer hook that flags structural +# violations after a file change. Non-blocking: prints warnings to stderr +# so Claude sees them in the next turn and self-corrects, while pre-commit +# and CI provide the hard enforcement. + +set -uo pipefail + +cd "$CLAUDE_PROJECT_DIR" + +WARN=0 + +# Read the edited path from the tool input (PostToolUse stdin is JSON). +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +PAYLOAD=$(cat) +FILE=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.file_path // empty') +[ -z "$FILE" ] && exit 0 +[ ! -f "$FILE" ] && exit 0 + +# Strip absolute prefix to get a path relative to the project root. +REL=${FILE#"$CLAUDE_PROJECT_DIR/"} + +# 1. Third-party imports in domain. +if [[ "$REL" == src/*/domain/* ]]; then + if ! uv run python scripts/checks/no_third_party_in_domain.py src/ >/tmp/audit-1.log 2>&1; then + echo "WARN: domain layer purity violated:" >&2 + cat /tmp/audit-1.log >&2 + WARN=1 + fi +fi + +# 2. Naive datetime in src/. +if [[ "$REL" == src/* ]]; then + if ! uv run python scripts/checks/no_naive_datetime.py "$FILE" >/tmp/audit-2.log 2>&1; then + echo "WARN: naive datetime detected:" >&2 + cat /tmp/audit-2.log >&2 + WARN=1 + fi +fi + +# 3. Test naming and GIVEN/WHEN/THEN body. +if [[ "$REL" == tests/* && "$(basename "$REL")" == test_*.py ]]; then + if ! uv run python scripts/checks/test_naming.py "$FILE" >/tmp/audit-3.log 2>&1; then + echo "WARN: test naming/format violation:" >&2 + cat /tmp/audit-3.log >&2 + WARN=1 + fi +fi + +# 4. Auto-format + lint Python files (ruff check --fix + ruff format). +# Surfaces remaining unfixable issues on stderr so Claude self-corrects on +# the next turn. Skipped on non-Python paths and on deleted files. +if [[ "$REL" == *.py ]]; then + uv run ruff check --fix "$FILE" >/tmp/audit-ruff-check.log 2>&1 + if [ $? -ne 0 ]; then + echo "WARN: ruff issues remaining after auto-fix on $REL:" >&2 + cat /tmp/audit-ruff-check.log >&2 + WARN=1 + fi + uv run ruff format "$FILE" >/tmp/audit-ruff-format.log 2>&1 || true +fi + +# 5. New DomainError without HTTP mapping. +if [[ "$REL" == src/*/domain/exceptions/* ]] && grep -q "class .*Error" "$FILE"; then + ERROR_HANDLERS=$(find src -path "*/presentation/api/error_handlers.py" | head -1) + if [ -n "$ERROR_HANDLERS" ]; then + NEW_CLASS=$(grep -oE "class [A-Za-z_]+Error" "$FILE" | awk '{print $2}' | head -1) + if [ -n "$NEW_CLASS" ] && ! grep -q "$NEW_CLASS" "$ERROR_HANDLERS"; then + echo "WARN: '$NEW_CLASS' is not registered in $ERROR_HANDLERS::ERROR_HTTP_MAPPING — add an entry or the test will fail." >&2 + WARN=1 + fi + fi +fi + +# Always exit 0; observers do not block. +exit 0 diff --git a/{{cookiecutter.project_slug}}/.claude/hooks/inject-context.sh b/{{cookiecutter.project_slug}}/.claude/hooks/inject-context.sh new file mode 100755 index 0000000..ea82a94 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/hooks/inject-context.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Claude UserPromptSubmit: injects a short reminder pointing to the right +# skill based on intent keywords detected in the user's prompt. Helps Claude +# pick the correct skill before acting. + +set -uo pipefail + +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +PAYLOAD=$(cat) +PROMPT=$(printf '%s' "$PAYLOAD" | jq -r '.prompt // empty' | tr '[:upper:]' '[:lower:]') + +if [ -z "$PROMPT" ]; then + exit 0 +fi + +emit() { + jq -n --arg context "$1" '{ "hookSpecificOutput": { "additionalContext": $context } }' +} + +# Order matters: most specific first. +if echo "$PROMPT" | grep -Eq "(migration|alembic|schema change|new table)"; then + emit "Reminder: use the database-and-migrations skill. Always read the autogenerate output, ensure downgrade() is non-empty, and add a round-trip migration test." +elif echo "$PROMPT" | grep -Eq "(new endpoint|add a feature|new use case|new api|create.*endpoint)"; then + emit "Reminder: use the building-a-feature skill, paired with tdd-workflow (failing test first). It walks plan -> use case -> persistence -> route -> wiring. The reference feature is the User signup flow." +elif echo "$PROMPT" | grep -Eq "(fix|bug|regression)"; then + emit "Reminder: use the tdd-workflow skill. Write a failing unit test that reproduces the bug before changing production code." +elif echo "$PROMPT" | grep -Eq "(domain error|new error|exception)"; then + emit "Reminder: use the writing-domain-code skill (exceptions section). New DomainError subclasses MUST be registered in presentation/api/error_handlers.py::ERROR_HTTP_MAPPING." +elif echo "$PROMPT" | grep -Eq "(helm|chart|deployment)"; then + emit "Reminder: use the writing-a-helm-change skill. Verify with helm lint + helm template -f values-prod.yaml | kubeconform." +elif echo "$PROMPT" | grep -Eq "(test|tdd|mutation)"; then + emit "Reminder: use the tdd-workflow skill. Unit tests use Fakes only (zero mocks); GIVEN/WHEN/THEN naming and body. E2E: 1 happy + 1 critical error per endpoint, do not duplicate use case branches." +fi + +exit 0 diff --git a/{{cookiecutter.project_slug}}/.claude/hooks/pre-commit-gate.sh b/{{cookiecutter.project_slug}}/.claude/hooks/pre-commit-gate.sh new file mode 100755 index 0000000..83b5fe0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/hooks/pre-commit-gate.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Claude PreToolUse(git commit): runs the same quality script as pre-commit +# would on a developer's machine. exit 2 blocks the commit. + +set -euo pipefail + +cd "$CLAUDE_PROJECT_DIR" + +if ! bash scripts/checks/quality.sh > /tmp/soma-quality.log 2>&1; then + echo "" + echo "Quality checks failed. Commit blocked." + echo "Tail of the log:" + tail -25 /tmp/soma-quality.log + echo "" + echo "Full log: /tmp/soma-quality.log" + exit 2 +fi + +exit 0 diff --git a/{{cookiecutter.project_slug}}/.claude/hooks/pre-push-gate.sh b/{{cookiecutter.project_slug}}/.claude/hooks/pre-push-gate.sh new file mode 100755 index 0000000..9eab08c --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/hooks/pre-push-gate.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Claude PreToolUse(git push): runs unit + integration tests. exit 2 blocks +# the push. CI catches everything else (mutation, e2e, helm, security). + +set -euo pipefail + +cd "$CLAUDE_PROJECT_DIR" + +if ! uv run pytest -m "unit or integration" -q > /tmp/soma-prepush.log 2>&1; then + echo "" + echo "Tests failed. Push blocked." + tail -40 /tmp/soma-prepush.log + echo "" + echo "Full log: /tmp/soma-prepush.log" + exit 2 +fi + +exit 0 diff --git a/{{cookiecutter.project_slug}}/.claude/settings.json b/{{cookiecutter.project_slug}}/.claude/settings.json new file mode 100644 index 0000000..d989fd0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/settings.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "deny": [ + "Bash(git push --force*)", + "Bash(git push --force-with-lease*)", + "Bash(git commit --no-verify*)", + "Bash(git rebase --no-verify*)", + "Bash(rm -rf *)", + "Read(.env)", + "Read(.env.*)", + "Read(**/secrets/**)" + ], + "ask": [ + "Bash(alembic downgrade*)", + "Bash(helm upgrade*)", + "Bash(helm install*)", + "Bash(kubectl delete*)", + "Bash(uv remove*)", + "Bash(docker system prune*)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash(git commit*)", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-gate.sh", + "timeout": 60 + } + ] + }, + { + "matcher": "Bash(git push*)", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-gate.sh", + "timeout": 180 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/audit-edit.sh" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/inject-context.sh" + } + ] + } + ] + } +} diff --git a/{{cookiecutter.project_slug}}/.claude/skills/adding-auth/SKILL.md b/{{cookiecutter.project_slug}}/.claude/skills/adding-auth/SKILL.md new file mode 100644 index 0000000..5ad8b53 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/skills/adding-auth/SKILL.md @@ -0,0 +1,206 @@ +--- +name: adding-auth +description: How bearer-JWT auth is wired and how to protect a route. Covers the seams (CurrentUser, TokenVerifier, dependencies), the swap path to a real IdP (HS256 → RS256 + JWKS), the role-based authz helper, and how tests inject identities without signing real tokens. +when_to_use: Protecting a new route; adding a role check; debugging a 401/403; swapping from the default HS256 secret to a real IdP (Keycloak, Auth0, Okta, Cognito); editing presentation/api/dependencies/auth.py or infrastructure/auth/jwt_verifier.py. +--- + +# Adding auth + +## The chain + +``` +Authorization: Bearer + ↓ get_bearer_token → MissingTokenError (401) + ↓ get_current_user → InvalidTokenError (401) + ↓ require_roles(...) → InsufficientPermissionsError (403) + ↓ + your handler +``` + +Files involved: + +| Layer | File | Role | +|---|---|---| +| application | `application/auth/current_user.py` | `CurrentUser` value object (subject, email, roles, claims) | +| application | `application/auth/token_verifier.py` | `TokenVerifier` Protocol | +| domain | `domain/exceptions/auth.py` | `MissingTokenError`, `InvalidTokenError`, `InsufficientPermissionsError` | +| infrastructure | `infrastructure/auth/jwt_verifier.py` | `JwtTokenVerifier` — HS256 or RS256+JWKS | +| presentation | `presentation/api/dependencies/auth.py` | `get_bearer_token`, `get_current_user`, `get_current_user_optional`, `require_roles` | +| presentation | `presentation/api/v1/auth.py` | Reference endpoint `GET /v1/auth/whoami` | + +### Exception codes (assert these in e2e tests) + +The class names use `…Error`; the `code` ClassVar that lands in +`response.json()["error"]["code"]` is prefixed with `AUTH_`: + +| Exception class | `code` | HTTP | +|---|---|---| +| `MissingTokenError` | `AUTH_MISSING_TOKEN` | 401 | +| `InvalidTokenError` | `AUTH_INVALID_TOKEN` | 401 | +| `InsufficientPermissionsError` | `AUTH_INSUFFICIENT_PERMISSIONS` | 403 | + +E2E tests should assert on the `AUTH_*` value (the contract clients +deserialize), not the class name (Python-only). + +## Protecting a route + +### Authentication only + +```python +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser +from {{ cookiecutter.package_name }}.presentation.api.dependencies.auth import get_current_user + +@router.get("/v1/profile") +async def read_profile(user: CurrentUser = Depends(get_current_user)) -> ProfileResponse: + ... +``` + +### Authentication + role check + +Use the dependency factory `require_roles(...)`. It runs `get_current_user` first, then validates membership. Pass roles as positional arguments; **at least one** must match (OR semantics, not AND). + +```python +from {{ cookiecutter.package_name }}.presentation.api.dependencies.auth import require_roles + +@router.delete( + "/v1/users/{user_id}", + dependencies=[Depends(require_roles("admin"))], # no need to receive the user +) +async def delete_user(user_id: UUID, use_case: ... = Depends(...)) -> None: + ... + +# Or, when the handler needs the user object: +async def archive_user( + user_id: UUID, + user: CurrentUser = Depends(require_roles("admin", "moderator")), +) -> None: + ... +``` + +### Optional auth (anonymous or authenticated) + +```python +from {{ cookiecutter.package_name }}.presentation.api.dependencies.auth import get_current_user_optional + +@router.get("/v1/feed") +async def feed(user: CurrentUser | None = Depends(get_current_user_optional)) -> FeedResponse: + if user is None: + return public_feed() + return tailored_feed(user) +``` + +A malformed Authorization header still raises `MissingTokenError` — that is a client bug, not "anonymous". + +## Configuration + +All under the `AUTH_*` env vars (see `.env.example`): + +| Variable | HS256 | RS256 | Notes | +|---|---|---|---| +| `AUTH_JWT_ALGORITHM` | `HS256` | `RS256` | Default `HS256` for dev / internal | +| `AUTH_JWT_SECRET` | **required** | unused | The shared secret. Pydantic-Settings wraps it in `SecretStr` | +| `AUTH_JWT_JWKS_URL` | unused | **required** | e.g. `https://auth.example.com/.well-known/jwks.json` | +| `AUTH_JWT_AUDIENCE` | optional | optional | Empty = no `aud` check | +| `AUTH_JWT_ISSUER` | optional | optional | Empty = no `iss` check | +| `AUTH_JWT_ROLES_CLAIM` | optional | optional | Dotted path — e.g. `realm_access.roles` for Keycloak | + +## Swap path — from HS256 to a real IdP + +For a real IdP (Keycloak, Auth0, Okta, AWS Cognito), the only changes you need are environment variables — no code change: + +```env +AUTH_JWT_ALGORITHM=RS256 +AUTH_JWT_JWKS_URL=https:///.well-known/jwks.json +AUTH_JWT_AUDIENCE= +AUTH_JWT_ISSUER= +AUTH_JWT_ROLES_CLAIM=realm_access.roles # Keycloak +# AUTH_JWT_ROLES_CLAIM=https://your-namespace/roles # Auth0 custom claim +``` + +`AUTH_JWT_SECRET` becomes unused under RS256 — the public keys are fetched from the JWKS URL and cached by `PyJWKClient`. + +For a more invasive swap (mTLS, opaque tokens, IdP introspection endpoint), edit `infrastructure/auth/jwt_verifier.py` or replace it with a different binding in `infrastructure/container.build_token_verifier`. The `TokenVerifier` protocol is the only seam — nothing else changes. + +## Mapping ``current_user.subject`` to a domain identifier + +`CurrentUser.subject` is the raw IdP ``sub`` claim — a string, no type +guarantee. Projects that use it as a domain identifier (e.g. converting +to ``UserId`` for an ownership check) must cast safely, because a +malformed claim raises in route code and becomes a 500: + +```python +# ❌ Crashes with 500 on a non-UUID subject +caller_id = UserId(UUID(current_user.subject)) +``` + +Two clean options: + +1. **Trust the IdP** — if the IdP guarantees ``sub`` is a UUID (Keycloak, + Auth0 with a custom user-id claim, …), do the cast in the route and + document that contract in the dependency wiring. + +2. **Validate at the boundary** — wrap the cast in a try/except and + raise ``InvalidTokenError`` on failure: + + ```python + try: + caller_id = UserId(UUID(current_user.subject)) + except ValueError as exc: + raise InvalidTokenError(reason="non_uuid_subject") from exc + ``` + +The second option is safer when the IdP is third-party-controlled (or +during a swap). Pick one and apply it consistently — mixing leads to +inconsistent error codes for the same root cause. + +## Tests + +### Unit tests of an authenticated handler + +Don't call the protected handler directly — instead test that the dependency wiring rejects bad input. The protected handler's body is best covered at the use-case level (no auth concerns) plus an e2e test (auth concerns). + +### E2E test of a protected route + +The e2e `client` fixture has the `TokenVerifier` overridden by `FakeTokenVerifier`. Register a token with `token_verifier.accept(...)` and send it as `Authorization: Bearer `. + +```python +def test_given_admin_token_when_calling_delete_then_returns_204( + client: TestClient, + token_verifier: FakeTokenVerifier, +) -> None: + # GIVEN + token_verifier.accept("admin-token", subject="alice", roles=("admin",)) + + # WHEN + response = client.delete("/v1/users/abc", headers={"Authorization": "Bearer admin-token"}) + + # THEN + assert response.status_code == 204 +``` + +The `FakeTokenVerifier` lives in `tests/fakes/auth.py` — read it for the available helpers. + +### Integration test of the production `JwtTokenVerifier` + +Already shipped under `tests/integration/infrastructure/auth/test_jwt_verifier.py` (signs a JWT with PyJWT, verifies through the production binding). When you change the verifier (custom claims, audience policy, …), update or add tests there. + +## Local dev — calling protected endpoints from curl + +```bash +just dev-token alice # plain authenticated user, no roles +just dev-token alice admin,user # with roles + +# Then: +curl -H "Authorization: Bearer $(just dev-token alice admin)" \ + http://localhost:8000/v1/auth/whoami +``` + +The recipe mints an HS256-signed token good for one hour using your local `AUTH_JWT_SECRET`. It refuses to run if `AUTH_JWT_ALGORITHM != HS256` — production deployments are not expected to use it. + +## Common mistakes + +* **Forgetting `Depends(get_current_user)` in the handler signature.** Without it, the route is public — auth is opt-in. +* **Using `require_roles` without a current_user injection in the route.** Either pass it as `dependencies=[...]` or as the user parameter. Both are valid; using neither makes the dependency tree not include it. +* **Leaking the verification failure cause to the response.** The `InvalidTokenError` deliberately uses a generic message; the `context` lands in the structured warning log only. Do not change this without a security review. +* **Adding a new `DomainError` subclass for auth without an entry in `ERROR_HTTP_MAPPING`.** The error-mapping unit test will fail and the request will fall back to 500. Always add the status code in `presentation/api/error_handlers.py`. +* **Using `AUTH_JWT_SECRET` in prod.** HS256 is for dev / internal services. In prod, the IdP signs with its private key and the service verifies via JWKS — `AUTH_JWT_ALGORITHM=RS256`. diff --git a/{{cookiecutter.project_slug}}/.claude/skills/building-a-feature/SKILL.md b/{{cookiecutter.project_slug}}/.claude/skills/building-a-feature/SKILL.md new file mode 100644 index 0000000..9fb24c4 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/skills/building-a-feature/SKILL.md @@ -0,0 +1,734 @@ +--- +name: building-a-feature +description: End-to-end runbook for delivering a feature — clarification questions, outside-in plan, outside-in TDD across use case → persistence → route, container wiring, mutation. Replaces the old orchestration/use-case/abstractions/transactions/idempotency skill bundle. Use this when the user asks for a new feature. +when_to_use: User asks for "a new feature", "a new use case", "a new endpoint", "let's implement ". Pair with tdd-workflow at every step that produces logic. +--- + +# Building a feature — outside-in end to end + +A new feature crosses all four layers. The discipline is **outside-in**: start from the feature description, write the failing outer test (use case with Fakes), let domain pieces emerge as the test demands them. NEVER pre-build value objects, entities, or Protocol methods that no test requires. + +This skill is the runbook the main agent follows. There is no sub-agent dispatch — every step runs in the current conversation with `tdd-workflow`, `writing-domain-code`, `onboarding-soma` (naming + DI + observability) loaded as references. + +## Flow — required runbook + +Every feature walks §1 → §11 in order, regardless of complexity. Each step is **one** of these: + +1. **Parse the prompt** — usually a one-liner. +2. **Ask clarification questions** using the question packs in §2. +3. **Produce the 5-section outside-in plan**. +4. **User validates** — MANDATORY HARD GATE. +5. **Use case TDD** — outer test, then `ImportError` chain (unit tests with Fakes). +6. **Persistence TDD** — SQLAlchemy model, repository binding, Alembic migration (integration tests with testcontainers). +7. **Route TDD** — Pydantic schemas, FastAPI route, container factory, `Depends` wrapper (1 happy + 1 critical e2e test). +8. **Container wiring** — register new factories in `infrastructure/container.py`. +9. **Mutate** — `just mutate` until kill rate ≥ 90%. +10. **Final lint** — `just lint`, GIVEN/WHEN/THEN check, domain purity check. +11. **Commit** — one conventional-commit per feature, no `--no-verify`. + +## Conditional appendices — load only when relevant + +Skip unless the trigger matches. Each appendix is self-contained at its anchor. + +| Appendix | Read when… | +|---|---| +| § Adding a new abstraction | the use case needs a collaborator role that is NOT one of `Clock`, `IdGenerator`, `RandomSource`, `EventBus`, `AuditLog`, `Repository`. | +| § Aggregates with nested children (inside §6) | the aggregate root owns a collection of child entities (TodoList → TodoItem[]). | +| § Testing external HTTP adapters | the feature has an outbound integration to a SaaS you cannot self-host (Stripe, Twilio, OpenAI, Auth0, …). | +| § Transactions and events | the feature emits a domain event, or you need to reason about transactional semantics beyond "one request, one transaction". | +| § Idempotency | the endpoint is `POST` / `PUT` / `PATCH` / `DELETE` AND the client is expected to retry on network failures. | + +--- + +## 1. Parse the prompt + +The user typically asks in one line: + +> *« Je veux pouvoir créer une TodoList, une seule par personne, avec un nom et une description »* + +That single line is NOT something you act on directly. The next step is asking the questions that ambiguities of that line raise. + +--- + +## 2. Clarification questions — pick from these packs + +Don't invent questions. Pick the relevant items from the packs below; each one is a known fork that changes observable behaviour. Skip a pack if the prompt already answers it. + +### Pack A — Scope & inputs +- A1. What fields are on the input? (For each: max length, optional or required, validation rules?) +- A2. Which fields are derived (server-generated id, timestamp, owner from auth) vs sent by the client? +- A3. What is the user-facing output? (full entity, or just an id, or 204?) + +### Pack B — Uniqueness & lifecycle +- B1. Is there a uniqueness rule? If yes, on which combination of fields? Scoped to whom (owner, tenant, global)? +- B2. Soft-delete or hard-delete? If soft, do soft-deleted rows count for uniqueness? +- B3. Can the entity be re-created after deletion? + +### Pack C — Auth & multi-tenancy +- C1. Public, authenticated, or role-restricted? (which roles?) +- C2. Is the action attributed to an actor (`current_user`)? How does it arrive in the use case (DTO field, separate collaborator)? +- C3. Multi-tenant scope — does the rule apply per-tenant? + +### Pack D — Events & side effects +- D1. Does the use case emit an event? If yes, the literal event name (`.`) and the payload semantics ("the consumer must be able to identify the new entity, its owner, …"). +- D2. Does the use case call a downstream system (email, webhook)? If yes, through which existing abstraction? + +### Pack E — Errors & HTTP +- E1. For each error path (duplicate, not found, validation failure), what HTTP status? +- E2. Is the endpoint idempotency-key-eligible? (POST/PUT/PATCH/DELETE writes are by default — see § Idempotency below.) + +### Pack F — Out of scope +- F1. Anything explicitly NOT in this slice? (list endpoint, delete endpoint, admin variant) + +Pick 4-6 questions. Ask them in a numbered list. The user answers in one message. + +--- + +## 3. The 5-section outside-in plan + +After the user answers, produce a one-page plan with exactly these five sections. + +> ⚠ **The "Tests that will be written" section is at use case (behavioural) level ONLY.** Adding bullets like "the `Name` VO rejects blank entries" or a "Domain (drill)" subsection is a leak — it pre-decides where validation lives (VO `__post_init__` vs entity factory vs use case body). That defeats outside-in TDD. The domain structure must emerge from the failing use case tests during the build, not be sketched in the plan. +> +> Bad: ❌ *"The `name` VO rejects blank or whitespace-only entries"* +> +> Good: ✅ *"GIVEN a blank name, WHEN creating, THEN the use case raises a validation error"* + +### 1. Tests that will be written +Behavioural cases expressed in GIVEN/WHEN/THEN at the use case level. One bullet per branch the user cares about. Example: +- *GIVEN an authenticated user with no list, WHEN they create one with a valid name, THEN the list is persisted and a `todo_list.created` event is emitted.* +- *GIVEN an authenticated user already owning a list named "Shopping", WHEN they try to create another with the same name, THEN the use case raises `` (the builder picks the exact class name).* + +### 2. Abstractions needed — by role, never by name +List the *roles* of the collaborators. Do **NOT** write `TodoListRepository` or `find_active_by_owner_and_name`. The implementation step names everything. +- *A repository for the new entity (uniqueness check + persistence).* +- *The existing Clock and IdGenerator from `application/`.* +- *EventBus (existing).* + +### 3. API surface — one line unless it gets weird +` `, auth scheme, status codes per outcome, idempotency flag. Elaborate **only** when something non-obvious is at play. +- `POST /v1/todo-lists` — authenticated — 201 happy, 409 on duplicate, 422 on validation — idempotency-key eligible. + +### 4. External contracts that ARE pre-decided +Pre-committed because third parties depend on them: +- Event: `todo_list.created` with `{ "id": "", "owner_id": "", "name": "" }`. +- HTTP error mapping: `` → 409, validation errors → 422. + +### 5. Behavioural questions to clarify +If anything is still ambiguous after the Q&A, list it here and stop. Otherwise: "none — ready to build". + +### What does NOT go in the plan + +- Class names (no "TodoList entity", no "TodoListId VO") +- Protocol method signatures (no "find_active_by_owner_and_name") +- Exception names or codes (the implementation picks them per `onboarding-soma` § naming) +- Entity field lists (a field is added when a test demands it) +- DTO field lists (same) + +These are **internal** and emerge from the tests during implementation. Pre-deciding them is anti-TDD. + +--- + +## 4. User validates — **MANDATORY HARD GATE** + +> **CRITICAL — DO NOT WRITE ANY PRODUCTION OR TEST CODE BEFORE THE USER REPLIES.** +> +> After posting the plan, **STOP**. No file edits. No `Edit`/`Write` tool calls. No `git` actions. No "while we wait, let me…". Only the user's next message can unblock you, and it must be an **explicit affirmative**. + +Show the plan. Then wait, in silence, for the user's reply. The reply must be one of: + +- An **explicit affirmative**: "go", "ok", "lance", "approuvé", "validé", a clear thumbs-up, etc. +- **Corrections**: the user revises part of the plan. Apply the revision, repost the updated plan, then return to STOP. +- **Questions**: answer the question. Do not assume the question was approval. Stay at STOP until an explicit affirmative arrives. + +Forbidden interpretations of a non-affirmative reply: + +- Silence → NOT approval. Wait. +- A clarifying question → NOT approval. Answer it, then wait. +- Acknowledgement of a piece of the plan ("oui ça c'est bon") → NOT approval of the rest. Wait. +- A user message that only adds context or constraints → NOT approval. Update the plan, repost, then wait. + +This gate exists because every minute of agent work spent on a wrong-shaped plan is wasted. Address all behavioural questions explicitly; do not "start with the safe parts" while ambiguity is unresolved. + +--- + +## 5. Use case TDD (unit tests with Fakes) + +Open `tests/unit/use_cases/test__.py` (or `tests/unit/use_cases//test_.py` once 3+ use cases share an aggregate — see the aggregate grouping rule below) and write the test as if every class and method already existed. Run the test. Get an `ImportError`. That's correct — the next file you create is the missing import. + +### Domain pieces emerge + +| Emerges when… | Where | Base | User reference | +|---|---|---|---| +| The test stores entity instances in the Fake | `domain/entities/.py` | `Entity[Id]` | `domain/entities/user.py` | +| The entity needs a typed identifier or a constrained scalar | `domain/value_objects/.py` | `ValueObject` + `@dataclass(frozen=True, slots=True)` | `domain/value_objects/email.py`, `user_id.py` | +| The test calls `pytest.raises(Error)` | `domain/exceptions/.py` | `DomainError` | `domain/exceptions/user.py` | + +For domain rules (no third-party, no `datetime.now()`, no `float`, no log), see `writing-domain-code`. + +### Application pieces emerge + +| Emerges when… | Where | Base | +|---|---|---| +| The Fake stores something — you need a Protocol to type-hint it | `application/repositories/.py` | `Repository[, Id]` Protocol — start empty; add business-named methods only when a test forces them. | +| The use case body needs a collaborator that doesn't exist yet (`EmailSender`, `Cache`, …) | `application/.py` | `typing.Protocol` | + +**Before declaring a new collaborator**, check the "Adding a new abstraction" section below. The existing protocols (`Clock`, `IdGenerator`, `RandomSource`, `EventBus`, `AuditLog`, `Repository`) cover most "I need a new port" thoughts. + +### Repository Protocol method extension + +The base `Repository[TEntity, TId]` Protocol ships `add` + `find_by_id`. Every other method is added by the test that needs it, named after the business question (`find_active_by_owner_and_name`), not the SQL operation (`select_where_owner_eq_and_name_eq`). Naming rules and anti-patterns: `onboarding-soma` § "Repository method naming". + +### Where each new file lands + +Kind-first (`use_cases/`, `value_objects/`, `dtos/`, …), aggregate-second when 3+ files in a kind belong to the same aggregate (then group in a sub-folder and drop the prefix). Full rule + examples: `onboarding-soma` § "File and folder names → Group by aggregate". + +### The use case body + +`application/use_cases/_.py` (flat) or `application/use_cases//.py` (grouped): + +```python +from {{ cookiecutter.package_name }}.application.use_cases.base import UseCase + + +class UseCase(UseCase[Input, Output]): + """One-line summary including the exceptions it may raise.""" + + def __init__(self, *, : , : ) -> None: + self._ = + self._ = + + async def execute(self, input: Input) -> Output: + ... +``` + +User reference: `application/use_cases/ensure_user_exists.py`. + +The `UseCase[TInput, TOutput]` generic surfaces input/output types at the class header. Cross-cutting concerns (OTel span, structured warning/error log on raise) are added by `_instrumented` in `infrastructure/container.py` — **never** inside the use case body. + +### DTOs + +`application/dtos/.py` — `Input` and `Output`, both `@dataclass(frozen=True, slots=True)`. Reference: `application/dtos/user.py`. + +### Next branches + +After the happy-path test is green, **each additional business rule is a NEW outer red** (uniqueness check, event emission, input validation, …). The red-green loop, the rule that one assertion per test fails for one reason, and the Fakes-only constraint at unit level all live in `tdd-workflow`. + +--- + +## 6. Persistence TDD (integration tests with testcontainers) + +Once the use case is green at unit level, write the SQLAlchemy binding. + +### Files to create + +| Need | Where | User reference | +|---|---|---| +| SQLAlchemy ORM model | `infrastructure/persistence/models/.py` (suffix `…Model`) | `infrastructure/persistence/models/user.py` | +| Concrete repository | `infrastructure/persistence/_repository.py` (`SqlAlchemyRepository`) with private `_to_entity` / `_to_model` | `infrastructure/persistence/user_repository.py` | +| Alembic migration | `just migration "add_"` then **read** the autogenerated revision before committing | `alembic/versions/0001_init.py` | + +For migration rules, see `database-and-migrations`. + +### Integration test pattern + +`tests/integration/infrastructure/test__repository.py`: + +- At least: happy `add` + `find_by_id`, integrity violation translated to the right `DomainError`, missing-id returns `None`. +- One distinct test per edge case (no `parametrize`). +- Format: GIVEN / WHEN / THEN. +- Use the `pg_session` fixture from `tests/integration/conftest.py`. + +Run `just test-integration` (Docker required — testcontainers spawns Postgres). + +User reference: `tests/integration/infrastructure/test_user_repository.py`. + +### Translating IntegrityError to DomainError + +Call `flush()`, **never** `commit()`. Flush surfaces integrity errors immediately so the repo can translate them into a `DomainError`. Commit is the dependency wrapper's job — see § Transactions below. + +```python +async def add(self, todo_list: TodoList, /) -> None: + self._session.add(self._to_model(todo_list)) + try: + await self._session.flush() + except IntegrityError as exc: + raise TodoListAlreadyExistsError(...) from exc +``` + +### Aggregates with nested children (e.g. TodoList → TodoItem[]) + +> **Read only if your aggregate root owns a collection of child entities. Otherwise skip to §7.** + +When the aggregate root owns a collection of child entities, the +persistence pattern needs three pieces: + +**1. Relationship on the ORM root**, with cascade + eager loading and +the column to sort children by: + +```python +class TodoListModel(Base): + ... + items: Mapped[list[TodoItemModel]] = relationship( + back_populates="todo_list", + cascade="all, delete-orphan", + lazy="selectin", + order_by="TodoItemModel.position", + ) +``` + +`cascade="all, delete-orphan"` makes a removed child in the domain +collection translate to a DELETE on the row. `lazy="selectin"` triggers +a second query at access time, but it's batched (no N+1). + +**2. `find_by_id` eager-loads** the children explicitly so the use case +gets the whole aggregate in one call: + +```python +async def find_by_id(self, id: TodoListId) -> TodoList | None: + stmt = ( + select(TodoListModel) + .where(TodoListModel.id == id.value) + .options(selectinload(TodoListModel.items)) + ) + model = (await self._session.execute(stmt)).scalar_one_or_none() + return self._to_entity(model) if model is not None else None +``` + +**3. A `save(aggregate)` method on the repository** that reconciles the +domain entity with its stored representation — new children get +inserted, existing children get updated, the rest is handled by +cascade. The use case calls `await repo.save(todo_list)` after any +mutation: + +```python +async def save(self, todo_list: TodoList) -> None: + stmt = ( + select(TodoListModel) + .where(TodoListModel.id == todo_list.id.value) + .options(selectinload(TodoListModel.items)) + ) + model = (await self._session.execute(stmt)).scalar_one() + # Update root-level fields the domain entity exposes. + model.name = todo_list.name + model.description = todo_list.description + model.deleted_at = todo_list.deleted_at + + # Diff children: insert new, update existing. + existing_by_id = {item.id: item for item in model.items} + for domain_item in todo_list.items: + existing = existing_by_id.get(domain_item.id.value) + if existing is None: + model.items.append(self._item_to_model(domain_item)) + else: + existing.name = domain_item.name + existing.description = domain_item.description + existing.position = domain_item.position + existing.completed_at = domain_item.completed_at + await self._session.flush() +``` + +The `save` method is added to the Protocol; the Fake repository +implements it as a no-op (or a rebind) since its dict-based storage +already reflects in-memory mutations by reference. + +--- + +## 7. Route TDD (e2e test) + +Once persistence is green, expose the use case over HTTP. + +### Files to create + +| Need | Where | User reference | +|---|---|---| +| Pydantic schemas (request + response) | `presentation/api/schemas/.py` (suffix `…Schema`) | `presentation/api/schemas/user.py` | +| `Depends` wrappers around container factories | `presentation/api/dependencies/.py` | `presentation/api/dependencies/users.py` | +| Versioned router | `presentation/api/v1/.py` | `presentation/api/v1/users.py` | +| Wire the router | add `app.include_router(_v1.router)` in `presentation/api/app.py` | already there for users | + +### Error → HTTP mapping + +If the use case raises a **new** `DomainError`, register it in `presentation/api/error_handlers.py::ERROR_HTTP_MAPPING`. The unit test `tests/unit/presentation/test_error_mapping.py` auto-discovers via `DomainError.__subclasses__()` and fails otherwise. + +If you want a different HTTP status for the same error type in different endpoints, that means you actually want a new error type — extract one. Don't override per-use-case. + +### HTTP status cheat sheet + +| Domain meaning | HTTP | +|---|---| +| Generic bad request, malformed body that Pydantic did not catch | 400 | +| Missing resource (lookup returned `None`) | 404 | +| Conflict (duplicate, version mismatch) | 409 | +| Resource removed permanently | 410 | +| Validation failure on a well-shaped input (business rule) | 422 | +| Authentication missing | 401 | +| Authorization insufficient | 403 | +| Payment required | 402 | +| Resource locked (concurrent edit) | 423 | +| Rate limited | 429 | +| Downstream service unavailable | 503 | + +### E2E test pattern + +`tests/e2e/api/test___endpoint.py` — **1 happy + 1 critical error**. Do NOT re-test use case branches here; they are already covered at the unit level. The `client` fixture gives a `TestClient` with the in-memory Fakes pre-wired. + +User reference: `tests/e2e/api/test_users_me_endpoint.py`. + +--- + +## 8. Container wiring + +`infrastructure/container.py`: + +```python +def build__repository(session: AsyncSession) -> Repository: + return SqlAlchemyRepository(session) + + +def build___use_case( + *, + s: Repository, + clock: Clock, + ids: IdGenerator, +) -> UseCase: + use_case = UseCase(s=s, clock=clock, ids=ids) + return _instrumented(use_case, span_name="_") +``` + +Every use case factory wraps with `_instrumented(span_name=...)`. For full DI rules, see `onboarding-soma` § DI container. + +--- + +## 9. Mutate + +```bash +just mutate # mutmut on domain + application +just mutate-results # latest report +``` + +Kill every surviving mutant by adding a focused test (outer if possible, inner if the mutation is inside a domain method). Target ≥ 90% kill rate. + +--- + +## 10. Final lint + +```bash +just lint # ruff + ty + 4 architectural lints + format +just test-all # unit + integration + e2e +just mutate # mutmut on domain + application; CI enforces ≥ 90% kill rate +``` + +--- + +## 11. Commit + +One feature = one conventional-commit. The agent commits **as soon as §10 is green**, without asking — it's a mechanical step, not a decision point. No `--no-verify`, no force push, no amends to commits the user already saw. + +```bash +git add -A +git commit -m "feat(): + +" +``` + +- **type**: `feat` for new capability, `fix` for a bug repro + fix, `refactor` only if behaviour is unchanged (rare in this flow). +- **scope**: the bounded context (`users`, `todolists`, `billing`…) — not the layer (never `feat(domain): …`). +- **body**: the *why*, not the diff. The diff is in the diff. A future reader greps `git log` to understand the decision. +- If pre-commit or commit-msg hooks reject the commit, fix the underlying issue and create a **new** commit. Never re-run with `--no-verify`. + +The user reviews the resulting commit (one focused diff, easy to read) instead of a sprawling working tree. If they want it split or reworded, that's a follow-up; the default is *committed before handing back control*. + +--- + +# Adding a new abstraction + +> **Read only if your use case needs a collaborator role that does not already exist as `Clock`, `IdGenerator`, `RandomSource`, `EventBus`, `AuditLog`, or `Repository`. Otherwise skip — the use case TDD step already covers wiring an existing collaborator.** + +When the use case needs a collaborator that doesn't exist yet. + +## STOP — check the existing ones first + +| Existing abstraction | Already covers — common mistakes | +|---|---| +| `Clock` (`application/clock.py`) | "What time is it?" Anything time-based. Never `datetime.now()` instead. | +| `IdGenerator` (`application/id_generator.py`) | New identifier generation. Don't write a `TokenGenerator` for opaque IDs — wrap or extend `IdGenerator`. | +| `RandomSource` (`application/random_source.py`) | Any random draw. Don't write a separate `JitterSource`. | +| `EventBus` (`application/event_bus.py`) | **Any** domain event leaving the use case asynchronously. Don't write a `MessagePublisher`. | +| `AuditLog` (`application/audit_log.py`) | Business-meaningful events ops/end-users will read. Don't write a `UserActivityRecorder`. | +| `Repository` | All persistence for an entity. Add a method to the existing repo before writing a new abstraction. | + +If the new collaborator fits one of these, the answer is: **add a method to the existing one**. + +## When a new abstraction IS justified + +The collaborator must satisfy all three: + +1. **Real external dependency or system primitive.** Calls out of the process **OR** a non-deterministic primitive you must fake in tests. +2. **Not a refinement of an existing role.** "Send a password-reset email" is the existing `EmailSender`, not a new abstraction. "Hash a password with Argon2" *is* new — unrelated to messaging. +3. **The use case would survive a swap.** If you replaced the production binding tomorrow, the use cases must not change. If only one implementation will ever exist and it is library-specific, keep the code concrete in `infrastructure/` — no abstraction needed. + +Common legitimate new abstractions: + +| Need | Stereotype | Class name | +|---|---|---| +| Send email / SMS / Slack | `Sender` | `EmailSender`, `SmsSender` | +| Call a paid SaaS | `Gateway` | `StripeGateway` | +| Hash / sign / verify | primitive | `PasswordHasher`, `TokenSigner` | +| Read or write blobs | `Reader` / `Writer` | `BlobReader`, `BlobWriter` | +| Cache lookup | `Cache` | `Cache` | +| Run something later | `Scheduler` | `Scheduler` | +| Read a feature flag | `Source` | `FeatureFlagSource` | + +## Add one — checklist + +1. **Abstract** — `application/.py` — `typing.Protocol`, zero third-party imports. +2. **Production binding** — `infrastructure/.py` — prefix by mechanism (`SendGridEmailSender`). +3. **Fake** — `tests/fakes/.py` — inspectable state, faithful invariants. +4. **Container factory** — `infrastructure/container.py` — `build_(settings)`. +5. **`Depends` wrapper** — `presentation/api/dependencies/.py`. +6. **Settings** — `infrastructure/config/settings.py` — `SecretStr` for credentials. +7. **Conftest fixture** if reused — `tests/conftest.py`. +8. **Integration test** if the production binding does I/O — `tests/integration/infrastructure/test_.py`. +9. **ADR** only when the choice is committing (e.g. "Outbox-backed EventBus over direct broker call"). + +--- + +# Testing external HTTP adapters + +> **Read only if the feature talks to an outbound SaaS you cannot self-host (Stripe, Twilio, OpenAI, Auth0, a partner webhook, …). Otherwise skip.** + +For an adapter that calls a SaaS we **cannot self-host**, testcontainers doesn't help. The canonical pattern for the template is **`respx`** in integration tests. Unit tests still use a Fake (`tests/fakes/.py`); `respx` is *only* for the integration test that exercises the real adapter against intercepted HTTP. + +## Why respx, not VCR or pytest-httpx + +- **Explicit response shapes** — the test declares what the external API is supposed to return. When the SaaS changes its contract, the test fails on the diff and you see it in PR review. VCR re-records silently and masks the drift. +- **Outbound assertions** — `respx_mock["my_route"].calls.last.request.json()` lets you check the payload your adapter sends, which is the primary thing an integration test for an outbound adapter should verify. +- **Route matchers** — respx has richer route definition than pytest-httpx (URL patterns, header matchers, named routes). For a template that ships generic patterns, the more expressive API ages better. + +The honest tradeoff: respx tests don't catch API additions (a new optional field the SaaS started returning that your code now silently ignores). Mitigation belongs in a contract-test workflow that hits the real sandbox on a cron, orthogonal to PR-time tests. + +## Already embarked + +`respx` is in the template's dev dependencies — use it for any adapter +you write with `httpx`. + +## Edge case: third-party libs that bypass httpx + +`respx` only intercepts `httpx`. Some third-party libraries make their +own HTTP calls through `urllib` / `requests` / `aiohttp` and respx +cannot see them. The template's `PyJWKClient` (RS256 JWKS fetcher, +shipped by PyJWT) is one such case — it uses `urllib`. The right tool +there is `monkeypatch` on the lib's fetcher boundary: + +```python +from jwt import PyJWKClient + +def test_...(monkeypatch: pytest.MonkeyPatch) -> None: + jwks = {"keys": [...]} + monkeypatch.setattr(PyJWKClient, "fetch_data", lambda _self: jwks) +``` + +The reference test +`tests/integration/infrastructure/auth/test_jwt_verifier.py::test_given_valid_rs256_token_when_verifying_then_fetches_jwks_and_returns_current_user` +shows the full pattern. Rule of thumb: **if your code uses `httpx`, +respx ; if the lib you depend on uses anything else, monkeypatch the +fetcher.** Never silently let HTTP escape in a test. + +## Pattern + +```python +# tests/integration/infrastructure/test_stripe_payment_gateway.py +import httpx +import pytest +import respx + +from {{ cookiecutter.package_name }}.application.dtos.payment import ChargeInput +from {{ cookiecutter.package_name }}.infrastructure.gateways.stripe_payment_gateway import ( + StripePaymentGateway, +) + + +@pytest.mark.integration +@respx.mock(base_url="https://api.stripe.com") +async def test_given_valid_charge_when_charging_then_posts_payload_and_returns_charge_id( + respx_mock: respx.MockRouter, +) -> None: + # GIVEN: the Stripe sandbox accepts the charge + route = respx_mock.post("/v1/charges").mock( + return_value=httpx.Response(200, json={"id": "ch_123", "status": "succeeded"}) + ) + gateway = StripePaymentGateway(api_key="sk_test_xxx", client=httpx.AsyncClient()) + + # WHEN + charge_id = await gateway.charge(ChargeInput(amount_cents=4200, currency="EUR")) + + # THEN: returns the upstream id + assert charge_id == "ch_123" + # THEN: sent the expected payload (this is the actual contract being verified) + assert route.called + assert route.calls.last.request.headers["Authorization"] == "Bearer sk_test_xxx" + assert route.calls.last.request.content == b"amount=4200¤cy=EUR" +``` + +Two assertions in one test on purpose: the integration test for an outbound adapter is verifying **both** the response parsing AND the request shaping — those are the same contract. + +## Failure cases + +Each independent failure path is its own test (one test, one reason to fail): + +- The SaaS returns a known error code → adapter raises the typed `DomainError`. Mock returns `httpx.Response(402, json={"error": ...})`, test asserts on the raised exception. +- The SaaS is unreachable → `httpx.ConnectError`. respx supports `route.mock(side_effect=httpx.ConnectError("boom"))`. Adapter must wrap it in a `DomainError` (e.g. `PaymentGatewayUnavailableError`). +- The response is malformed → mock returns invalid JSON. Adapter must raise, not silently return `None`. + +## Where the Fake lives + +Unit tests for the use case never touch respx — they get a `FakeStripePaymentGateway` from `tests/fakes/payment_gateway.py` injected via the container override. The Fake stores submitted charges in a list and accepts `accept_charge(id="ch_test")` / `reject_with(error_code="card_declined")` knobs. The integration test above is the **only** place the real `StripePaymentGateway` runs. + +--- + +# Transactions and events + +> **Read only if the feature emits a domain event, OR if you need to reason about transactional semantics beyond "one HTTP request = one transaction" (which is the default and already correct for plain CRUD). Otherwise skip — the default request-scoped session and savepoint handling are documented inline in §6 above.** + +## Transaction boundary — one HTTP request = one transaction + +`presentation/api/dependencies/common.py::get_session` opens both an `AsyncSession` and an outer transaction: + +```python +async with factory() as session, session.begin(): + yield session +``` + +The transaction **commits** when the route handler returns successfully, and **rolls back** on any raised exception — including `DomainError`. So a use case that has done a partial write and then raises leaves the database untouched. + +### What this implies for repositories + +Call `flush()`, **never** `commit()`. See § 6 above. + +### What this implies for use cases + +- **No `session.begin()`, no `commit()`, no `rollback()` in `application/`.** The use case never sees transactions. +- **Multiple writes are atomic by default.** +- **Outbox events ride the same transaction.** Calling `events.publish(...)` writes to `outbox_events` via the same session, so the entity write and the event row are atomic. +- **`_instrumented` cooperates** — when the use case raises a `DomainError`, the wrapper logs it at `WARNING` and re-raises; the dependency wrapper rolls back. + +### Sub-transactional checkpoints (rare) + +If you need a sub-step to fail without aborting the outer transaction: + +```python +async with session.begin_nested(): + ... # this block can fail without aborting the outer transaction +``` + +## Publishing a domain event + +Use cases publish through the `EventBus` protocol. Production binding: `SqlOutboxEventBus` — writes to `outbox_events` **inside the request transaction**. A separate `outbox_relay` worker polls unpublished rows and dispatches to handlers. + +```python +class RegisterOrderUseCase(UseCase[RegisterOrderInput, RegisterOrderOutput]): + def __init__(self, *, orders: OrderRepository, clock: Clock, events: EventBus) -> None: + self._orders = orders + self._clock = clock + self._events = events + + async def execute(self, input: RegisterOrderInput) -> RegisterOrderOutput: + order = Order.place(...) + await self._orders.add(order) + await self._events.publish( + "order.placed", + {"order_id": str(order.id.value), "total_cents": order.total.value}, + ) + return RegisterOrderOutput(order_id=order.id) +``` + +### Event naming convention + +`.` in dotted lowercase: `user.created`, `order.placed`, `payment.refunded`. Aggregate is the noun the event is about; verb is past-tense (the event records a fact, not an intent). Avoid `user.creating` (intent) or `userCreated` (camel). + +The payload is a flat `dict[str, Any]` of strings/ints/iso-dates — keep it small and stable, consumers depend on it. + +### Handlers + +Handlers live in `infrastructure/jobs/event_handlers.py` and register themselves via the `@handler("")` decorator: + +```python +from {{ cookiecutter.package_name }}.infrastructure.jobs.handlers import handler + + +@handler("user.created") +async def handle_user_created(payload: dict) -> None: + """Send a welcome email, warm up a cache, …""" + ... +``` + +Multiple handlers per event are allowed — they run sequentially. **At-least-once delivery**: make handlers idempotent. + +### Where the swap point is + +`infrastructure/jobs/handlers.py::dispatch` is the ONE function to replace when you wire a real broker (Kafka, Pub/Sub, SQS, …). Nothing else in the relay or in use cases changes. + +### The relay worker + +`infrastructure/jobs/outbox_relay.py` polls `outbox_events`, dispatches to handlers, marks rows published. Multi-replica safe via `SELECT FOR UPDATE SKIP LOCKED`. Backoff schedule with poison after 10 attempts. Metrics: `outbox_events_pending`, `outbox_oldest_pending_age_seconds`, `outbox_handler_failures_total{event_name}`. + +For full row lifecycle, backoff schedule, alerting thresholds, and manual replay queries, see `docs/adr/0005-outbox-event-bus.md`. + +--- + +# Idempotency + +> **Read only if your new endpoint writes (POST / PUT / PATCH / DELETE) AND the client is expected to retry on network failures. Pure GET endpoints, and endpoints whose clients never retry, do not need this section.** + +The `IdempotencyMiddleware` lets clients retry POST/PUT/PATCH/DELETE without double-effects. Clients add the header `Idempotency-Key: ` on retry-eligible writes. + +## Outcomes on retry + +| Retry with same key… | Server response | +|---|---| +| ...same method + same path, original completed | Original 2xx/4xx response replayed (handler does NOT run again) | +| ...same method + same path, original still in-flight | `409 IDEMPOTENCY_IN_PROGRESS` (retry later) | +| ...different method or path | `422 IDEMPOTENCY_KEY_MISMATCH` | +| ...key TTL elapsed (default 24h) | Treated as a fresh request, handler runs | + +## Configuration + +| Setting | Default | What it does | +|---|---|---| +| `IDEMPOTENCY_ENABLED` | `true` | Master switch | +| `IDEMPOTENCY_METHODS` | `POST,PUT,PATCH,DELETE` | Methods that opt in | +| `IDEMPOTENCY_TTL_SECONDS` | `86400` (24h) | After this delay a row is recycled | + +## Limitations (intentional) + +- **No body hash check** — two retries with the same key but different bodies receive the cached response of the first. +- **No automatic cleanup** — ship a CronJob: `DELETE FROM idempotency_records WHERE expires_at < now() - interval '7 days'`. +- **Streaming responses are not cached**. +- **Per-process scoping** — the cache key is the bare `Idempotency-Key` string. +- **Not exercisable via the e2e ``client`` fixture.** The middleware reads `app.state.session_factory` which is wired by the FastAPI lifespan; the e2e fixture deliberately bypasses the lifespan to avoid a real DB engine. So `tests/e2e/api/...` cannot verify replay semantics — they only confirm the endpoint routes correctly. The middleware itself is covered by `tests/integration/middleware/test_idempotency.py` (real Postgres via testcontainers). When you add a new write endpoint, do NOT write an e2e test that sends two requests with the same `Idempotency-Key` expecting the second to be replayed — the middleware will log `idempotency.no_session_factory` and pass through. + +## Client-side patterns + +- Generate one UUID v4 **per logical action**. +- Do NOT regenerate on retry. +- Discard the key after success. + +## Mistakes to avoid + +- **Treating the cached response as authoritative state.** A replay returns the *original* response. +- **Using a deterministic key.** Two unrelated requests with the same composite get falsely deduplicated. +- **Returning the cached row from the use case layer.** The middleware handles replay at the presentation boundary. +- **Forgetting the cleanup CronJob in production.** + +--- + +# Anti-patterns for the whole flow + +- **Pre-deciding domain pieces in the plan.** Class names, Protocol method names, exception names, entity fields — all emerge from tests. +- **Skipping the question packs.** "I'll just build it" produces a feature whose business rules are wrong, then needs redo. +- **Writing the use case body before the outer test compiles.** That's not TDD; that's typing code while occasionally running tests. +- **Adding a new abstraction without checking the existing ones first.** Most "I need a new port" is a use of an existing one. +- **Writing the persistence binding before the use case is green.** The Protocol contract is the brief; you can't implement what isn't there. +- **Re-testing use case branches at e2e level.** 1 happy + 1 critical only. Branch coverage lives at the unit level. +- **Skipping mutation.** Line coverage without mutation kill rate is a comforting lie. diff --git a/{{cookiecutter.project_slug}}/.claude/skills/database-and-migrations/SKILL.md b/{{cookiecutter.project_slug}}/.claude/skills/database-and-migrations/SKILL.md new file mode 100644 index 0000000..4a488db --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/skills/database-and-migrations/SKILL.md @@ -0,0 +1,111 @@ +--- +name: database-and-migrations +description: Schema changes with Alembic on async SQLAlchemy 2.0. Where ORM models live, how to add a table or a column, how to generate and review a revision, what CI catches, how the schema reaches production. +when_to_use: Adding or changing an ORM model under infrastructure/persistence/models/; generating an Alembic revision; debugging a migration; reviewing a schema change. +--- + +# Database and migrations + +Stack: SQLAlchemy 2.0 async + asyncpg + Alembic with an async env. The `0001_init.py` revision shipped with the template creates the three tables wired by default (`users`, `outbox_events`, `idempotency_records`) — open it alongside this skill as the canonical reference for every pattern below. + +## Where things live + +* **ORM models** — `src//infrastructure/persistence/models/.py`. Suffix `…Model` (`UserModel`, `OrderModel`). Models **never leave** `infrastructure/persistence/`. +* **Repositories** — `src//infrastructure/persistence/_repository.py`. Map `Entity ↔ Model` in private `_to_entity` / `_to_model` static methods; expose only domain entities outward. +* **Alembic migrations** — `alembic/versions/_.py`. One per logical schema change. + +For the transactional behaviour repositories run under (commit at request boundary, no `commit()` inside the repo), see `building-a-feature` § "Transactions and events". + +## Adding a new model + +```python +# infrastructure/persistence/models/.py +from datetime import datetime +from uuid import UUID + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.base import Base + + +class Model(Base): + """``s`` table mapping.""" + + __tablename__ = "s" + + id: Mapped[UUID] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) +``` + +Reference: `infrastructure/persistence/models/user.py`. + +Conventions: + +* **UUID primary keys** via `sa.Uuid()` (portable across PG and SQLite). Project-side `…Id` value object wraps it. +* **TZ-aware timestamps** always: `DateTime(timezone=True)`. The `no_naive_datetime` lint enforces it in production code; mirror it here. +* **Explicit `nullable=False`** on every non-optional column. Pydantic-style "I assume not-null" does not apply to SQLAlchemy. +* **`unique=True` + `index=True`** when the column is queried by value (e.g. `email` in `find_by_email`). +* **String length** matters in Postgres — set `String(N)` rather than unbounded `Text` unless you really need it. + +## Generating a migration + +```bash +just migration "add_" +``` + +Behind the scenes: `alembic revision --autogenerate -m "..."` against the local DB. The resulting file lands under `alembic/versions/`. + +**Read it before committing.** Autogenerate is a starting point, not the truth. Common things to fix by hand: + +* **Destructive ops** (`op.drop_*`) — confirm they are intended. Renaming a column comes out as a drop + an add by default; you usually want `op.alter_column(... new_column_name=...)` instead. +* **NOT NULL on a column added to a non-empty table** — autogenerate produces `nullable=False` without a `server_default`. Add the default, or split into three migrations: add nullable → backfill → set NOT NULL. +* **Ordering** — FK target tables must be created before the FK column. If autogenerate gets it wrong, reorder the `op.*` calls. +* **`server_default`** for timestamp columns — use `sa.text("now()")` if the column is meant to default to insert time: + + ```python + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")) + ``` + +The CI workflow `ci-migration-drift.yml` runs `alembic revision --autogenerate` against the current models in a fresh Postgres and fails the PR if the generated revision contains any `op.*` calls — meaning a model changed without a committed migration. This catches the most common review miss. + +## Downgrade is mandatory + +Every migration must implement `downgrade()` non-trivially. The `script.py.mako` raises `NotImplementedError` if you forget. Round-trip the migration locally before opening the PR: + +```bash +just migrate # upgrade head +just migrate-down # repeat until base if needed +just migrate # upgrade head again +``` + +If the round trip fails, the migration is wrong, not the test. + +## Adding a column to an existing table + +```bash +just migration "add__to_" +``` + +The autogenerated revision will contain `op.add_column(...)`. Patches to apply: + +* If the column is NOT NULL and the table already has rows, add `server_default=...` or split into 3 steps as above. +* If the column is indexed, `op.create_index(...)` in the same revision. +* If the column is part of a unique constraint, prefer `op.create_unique_constraint` over `unique=True` on the column when the constraint spans multiple columns. + +The `downgrade()` is symmetric: `op.drop_column(...)` (drop the index first if any). + +## Testing the new repository + +When you add the repository method that reads/writes the new column, write the integration tests next to the existing User reference: + +* `tests/integration/infrastructure/test__repository.py` +* Use the `pg_session` fixture (testcontainers Postgres, transaction rolled back per test). See `tdd-workflow` § "Real systems at integration". +* One test per edge case, no `parametrize`. The `test_user_repository.py` shipped with the template is the model to copy. + +## Production + +The Helm chart's `migration-job.yaml` runs `alembic upgrade head` as a Helm `pre-install` / `pre-upgrade` hook (weight `-5`) before any pod boots on the new image. To opt out (for projects that manage their schema differently), set `migrations.enabled: false` in `values.yaml`. + +Inside the devcontainer, migrations are also applied automatically on `postStart`. diff --git a/{{cookiecutter.project_slug}}/.claude/skills/event-sourcing-pattern/SKILL.md b/{{cookiecutter.project_slug}}/.claude/skills/event-sourcing-pattern/SKILL.md new file mode 100644 index 0000000..200768c --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/skills/event-sourcing-pattern/SKILL.md @@ -0,0 +1,332 @@ +--- +name: event-sourcing-pattern +description: When and how to event-source an entity in this codebase. Covers the SOMA-recommended "lite ES" variant (4 files per entity, synchronous projection, strong consistency) and explicitly delineates when to upgrade to strict CQRS instead. Documents the five places where the pattern leaks (schema evolution, queries, GDPR, debugging, performance) and how each is handled. +when_to_use: An entity needs a complete audit trail, time-travel queries, or replay capability; you're considering whether to event-source a new entity; an existing CRUD entity is being refactored to ES; you're reviewing a PR that introduces an event store; somebody asked "should we event-source X?". +--- + +# Event sourcing pattern (SOMA variant) + +The template is **CRUD by default**. Event sourcing is opt-in **per entity** when an audit trail, time travel, or replay capability is genuinely required by the domain. Most SOMA entities will never need it; do not adopt this pattern speculatively. + +## Decision table — should this entity be event-sourced? + +| Symptom | Verdict | +|---|---| +| Compliance/audit demands a full history of every change | **Event-source** | +| Domain has many state transitions worth modeling explicitly (workflow, lifecycle) | **Event-source** | +| You'll need "what did X look like on date Y" queries in production | **Event-source** | +| You expect to derive several projections from the same facts (admin / customer / analytics views) | **Event-source** AND graduate to strict CQRS — see end of doc | +| Simple CRUD profile / settings / configuration | **CRUD** (template default) | +| Catalog / reference data | **CRUD** | +| Reporting / read-mostly | **CRUD** | +| Domain still in exploration; the model changes every sprint | **CRUD** — versioning events you'll throw away is wasted effort | +| Right-to-be-forgotten is a frequent operation | **CRUD** (or accept the crypto-shredding overhead) | + +When in doubt: **start CRUD**. The lite ES variant below makes a retrofit reasonable later; over-engineering up front is harder to undo than under-engineering. + +## The SOMA "lite ES" variant — 4 files per entity + +Strict CQRS ships a separate read model with an asynchronous projector. We don't. We use the **same `` table** as both the read model AND the projection of the events. Updates to the table happen **inside the same transaction** as the event append, so reads stay strongly consistent and the async-projector machinery is unnecessary. + +This trades the option of multiple read models for half the code and zero eventual-consistency surface. The Graduation section at the end explains when to give that up. + +### File 1 — `domain/events/.py` + +The typed facts the entity emits. Each event is a frozen `ValueObject`. + +```python +from dataclasses import dataclass +from datetime import datetime + +from {{ cookiecutter.package_name }}.domain.value_objects.base import ValueObject + + +@dataclass(frozen=True, slots=True) +class UserRegistered(ValueObject): + user_id: str + email: str + name: str + at: datetime + + +@dataclass(frozen=True, slots=True) +class UserEmailChanged(ValueObject): + user_id: str + old_email: str + new_email: str +``` + +Events are **named after business facts** (`UserEmailChanged`), not technical operations (`UserUpdated`). The fact-orientation is the whole point — `update` could mean anything. + +### File 2 — `infrastructure/persistence/event_store.py` + +The append-only log. One generic table (`events`) shared across entities, with `aggregate_type` filtering. + +```python +class EventStore: + """Append-only log of domain events. Generic; shared across entities.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def load(self, *, aggregate_type: str, aggregate_id: UUID) -> list[DomainEvent]: + rows = await self._session.execute( + select(EventModel) + .where(EventModel.aggregate_type == aggregate_type) + .where(EventModel.aggregate_id == aggregate_id) + .order_by(EventModel.sequence) + ) + return [self._deserialize(row) for row in rows.scalars()] + + async def append( + self, + *, + aggregate_type: str, + aggregate_id: UUID, + events: list[DomainEvent], + expected_version: int, + ) -> None: + current = await self._session.scalar( + select(func.coalesce(func.max(EventModel.sequence), 0)) + .where(EventModel.aggregate_type == aggregate_type) + .where(EventModel.aggregate_id == aggregate_id) + ) + if current != expected_version: + raise OptimisticConcurrencyError(...) + for offset, event in enumerate(events, start=1): + self._session.add(EventModel( + aggregate_type=aggregate_type, + aggregate_id=aggregate_id, + sequence=expected_version + offset, + event_type=type(event).__name__, + event_version=getattr(event, "version", 1), + payload=asdict(event), + )) + await self._session.flush() +``` + +### File 3 — `infrastructure/persistence/event_sourced__repository.py` + +The Repo Protocol implementation. **This is the only file where "this entity is event-sourced" is visible.** Reads use the regular `` table (the projection); writes append events AND update the table in the same transaction. + +```python +class EventSourcedUserRepository(UserRepository): + def __init__(self, session: AsyncSession, event_store: EventStore) -> None: + self._session = session + self._events = event_store + + async def find_by_id(self, id: UserId) -> User | None: + # Reads go through the projection table (= the regular `users` table). + row = await self._session.get(UserModel, id.value) + return self._to_user(row) if row else None + + async def find_by_email(self, email: Email) -> User | None: + row = await self._session.scalar( + select(UserModel).where(UserModel.email == str(email)) + ) + return self._to_user(row) if row else None + + async def save(self, user: User) -> None: + # 1. Append events (audit trail / source of truth) + await self._events.append( + aggregate_type="User", + aggregate_id=user.id.value, + events=user._pending_events, + expected_version=user._version, + ) + # 2. Apply each event to the read-model table — same transaction. + for event in user._pending_events: + self._apply(event) + user._pending_events.clear() + user._version += len(user._pending_events) + + def _apply(self, event: DomainEvent) -> None: + if isinstance(event, UserRegistered): + self._session.add(UserModel( + id=UUID(event.user_id), + email=event.email, + name=event.name, + created_at=event.at, + )) + elif isinstance(event, UserEmailChanged): + row = self._session.execute( + update(UserModel) + .where(UserModel.id == UUID(event.user_id)) + .values(email=event.new_email) + ) + # ... +``` + +### File 4 — `alembic/versions/000X_init_events_table.py` + +Generic events table; idempotent so multiple entities adopting ES don't conflict. + +```python +def upgrade() -> None: + op.create_table( + "events", + sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")), + sa.Column("aggregate_type", sa.String(64), nullable=False), + sa.Column("aggregate_id", sa.Uuid(), nullable=False), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("event_type", sa.String(255), nullable=False), + sa.Column("event_version", sa.Integer(), nullable=False, server_default="1"), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("aggregate_type", "aggregate_id", "sequence"), + ) + op.create_index( + "ix_events_aggregate", + "events", + ["aggregate_type", "aggregate_id", "sequence"], + ) +``` + +## The domain entity changes too (not a separate file, but unavoidable) + +The entity must track its pending events and expose its version. This is the **one place the ES discipline is visible to anyone authoring the domain** — the use case writer never sees it. + +```python +class User(Entity[UserId]): + def __init__(self, ...) -> None: + super().__init__(...) + self._pending_events: list[DomainEvent] = [] + self._version: int = 0 + + @classmethod + def register(cls, *, id: UserId, email: Email, name: str, now: datetime) -> "User": + user = cls(id=id, email=email, name=name, created_at=now) + user._record(UserRegistered(user_id=str(id.value), email=str(email), name=name, at=now)) + return user + + def change_email(self, new_email: Email) -> None: + if self.email == new_email: + return + self._record(UserEmailChanged( + user_id=str(self.id.value), + old_email=str(self.email), + new_email=str(new_email), + )) + self.email = new_email + + def _record(self, event: DomainEvent) -> None: + self._pending_events.append(event) + + @classmethod + def from_events(cls, events: list[DomainEvent]) -> "User": + """Replay used by ``EventStore`` consumers — admin tools, projection rebuild.""" + user = None + for event in events: + if isinstance(event, UserRegistered): + user = cls( + id=UserId(UUID(event.user_id)), + email=Email(event.email), + name=event.name, + created_at=event.at, + ) + elif isinstance(event, UserEmailChanged): + user.email = Email(event.new_email) + user._version = len(events) + return user +``` + +The use case is unchanged from the CRUD version — it calls `user.change_email(...)` and `users.save(user)`. Whether the impl is CRUD or ES is the container's decision. + +## The five leaks (and how each is handled) + +ES is not a transparent storage detail; this section is honest about where the abstraction breaks. + +### 1. Adding a query method + +Adding `find_active_in_country(country)` to the Repo Protocol: + +* **CRUD impl**: trivial SQL. +* **ES impl**: trivial SQL **because the read model is the same table**. The lite ES variant pays nothing extra here. (Strict CQRS would force a new projection.) + +This is the biggest win of the lite variant: query methods stay easy. + +### 2. Adding a field to the entity + +Adding `User.locale`: + +* CRUD: column migration, default value. Done. +* Lite ES: column migration on `users` AND + * Pick a versioning strategy for `UserRegistered`: + * **Default value at deserialization**: simplest; `locale` defaults to `"en"` for old events. Loses the distinction "before this feature existed" vs "explicitly chose default". + * **Versioned event class**: ship `UserRegisteredV2`. Both versions handled in `from_events` and `_apply`. More code, exact semantics. + * Update `_apply(UserRegistered)` to populate the new column. + * Update `from_events` if the field affects replayed state. + +The skill `database-and-migrations` covers the column part; this skill covers the event versioning part. + +### 3. GDPR right-to-be-forgotten + +* CRUD: `DELETE FROM users WHERE id = ?`. 5 seconds. +* Lite ES: events are immutable. Pick a strategy: + * **Crypto-shredding**: PII fields in events encrypted with a per-user key. Delete the key → events become unintelligible. Setup overhead but legally clean. + * **Tombstone event**: emit `UserForgotten`, scrubs the projection (UPDATE users SET email=NULL, name='[forgotten]'). The original events remain but downstream consumers honor the tombstone. Audit trail acknowledges erasure occurred. + * **True deletion**: DELETE the events. Breaks append-only; audit trail loses the user. Last resort. + +Pick the strategy at the bounded context level, not per request. Document it in `docs/runbooks/`. + +### 4. Debugging "the projection shows the wrong value" + +In lite ES the projection is updated synchronously, so there is no projector lag. If the projection disagrees with the events, it's a bug in `_apply` or in `from_events`, not a race. Investigation: + +```sql +-- The events: +SELECT sequence, event_type, payload FROM events +WHERE aggregate_type = 'User' AND aggregate_id = '' +ORDER BY sequence; + +-- The projection: +SELECT * FROM users WHERE id = ''; +``` + +If they don't match: check the repo's `_apply` is exhaustive over event types AND the entity's `from_events` matches what `_apply` writes. The two must stay in sync — a fixture test that compares "applied via repo" against "replayed via from_events" catches drift before prod. + +### 5. Performance — replay cost grows with event count + +Lite ES `find_by_id` reads the `users` row (O(1)). No replay needed for reads. Only `from_events` (used by admin tools, projection rebuild) walks the full event list. + +If `from_events` becomes a hot path (rare), add **snapshots** — but the SOMA recommendation is to delay this until you see real numbers. Premature snapshot logic is the second-worst kind of complexity in ES, right after premature versioning. + +## Migrating an existing CRUD entity to lite ES + +Steps (per entity, not bulk): + +1. **Decide explicitly.** The decision table at the top must yield "event-source". +2. **Scaffold the four ES files** for that entity, following the file layout in this skill: the `events` table-row helper, the aggregate repository (`EventSourcedRepository`) that hydrates from events, the projection table (read model), and the projection updater. The main agent writes these directly — there is no dedicated sub-agent. +3. **Rewrite the domain entity** to track `_pending_events` and emit events from each mutation method. Scaffolding cannot do this for you — the events are business facts that need careful naming. +4. **Wire the new repo in `container.py`**: swap `build_user_repository` to construct the `EventSourcedUserRepository` instead of `SqlAlchemyUserRepository`. +5. **Backfill events from existing rows**: + ```sql + INSERT INTO events (aggregate_type, aggregate_id, sequence, event_type, payload, created_at) + SELECT 'User', id, 1, 'UserRegistered', + json_build_object('user_id', id::text, 'email', email, 'name', name, 'at', created_at), + created_at + FROM users; + ``` +6. **Test the round trip**: integration test that builds a User via use case, then `from_events(events_table_rows)` returns the same User. +7. **Ship**, monitor for `OptimisticConcurrencyError` spikes (sign of bad version tracking). + +## Cross-link with the outbox + +The `events` table is **local audit / replay**. The `outbox_events` table is **inter-service notification**. They serve different purposes and can coexist: + +* The lite ES repo writes to `events` (audit trail). +* If the entity also publishes notifications to downstream services, the use case `await events_bus.publish(...)` writes to `outbox_events` as before. +* Both writes happen in the same request transaction → atomic. + +A future evolution: the `outbox_relay` could project events into a SOMA-wide event archive (S3/BigQuery), giving cross-service audit + analytics without forcing strict CQRS in any service. + +## When to graduate to strict CQRS + +Switch to a separate async projection (the "7-file" variant) when: + +* You need **more than one read model** for the same events (admin view + customer view + analytics view). +* The read model needs to live in a different store (Elasticsearch for full-text, ClickHouse for analytics) than the write side. +* The write throughput needs to scale independently of the read throughput. + +Until those constraints bite, lite ES is the better default — it keeps strong consistency and avoids the projector pod. diff --git a/{{cookiecutter.project_slug}}/.claude/skills/onboarding-soma/SKILL.md b/{{cookiecutter.project_slug}}/.claude/skills/onboarding-soma/SKILL.md new file mode 100644 index 0000000..085a833 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/skills/onboarding-soma/SKILL.md @@ -0,0 +1,658 @@ +--- +name: onboarding-soma +description: The SOMA manual — Clean Architecture layers, naming conventions, DI container, observability patterns, and the User reference feature. Single source of truth for "how SOMA code is shaped". Use on a fresh session or any time you need to decide where code lives, what to name it, how to wire it, or how to log it. +when_to_use: First session on this repo. "Where does X go?", "what should I name this?", "how do I inject this?", "where should I log?". Editing infrastructure/container.py, infrastructure/observability/, presentation/api/dependencies/. Reviewing layer/naming questions in a PR. +--- + +# {{ cookiecutter.project_name }} — The SOMA manual + +This skill is the consolidated reference for SOMA conventions. It absorbs what used to be four separate skills (layers, naming, DI, observability) plus the project orientation. Use the table of contents to jump. + +## Table of contents + +1. [What this project is](#1-what-this-project-is) +2. [The four layers](#2-the-four-layers) +3. [Where does X go?](#3-where-does-x-go) +4. [The User reference feature](#4-the-user-reference-feature) +5. [Naming conventions (Uncle Bob strict)](#5-naming-conventions-uncle-bob-strict) +6. [The DI container](#6-the-di-container) +7. [Observability patterns](#7-observability-patterns) +8. [Hard rules digest](#8-hard-rules-digest) +9. [Five commands you need first](#9-five-commands-you-need-first) +10. [Where to look next](#10-where-to-look-next) + +--- + +## 1. What this project is + +A Python service following **Clean Architecture (Uncle Bob, strict)**: + +``` +src/{{ cookiecutter.package_name }}/ +├── domain/ # entities, value objects, exceptions — stdlib only +├── application/ # use cases + abstractions (Clock, IdGenerator, Repository, EventBus, AuditLog) +├── infrastructure/ # SQLAlchemy, structlog, OTel, DI container — concretes +└── presentation/ # FastAPI routes, schemas, error handlers, middleware +``` + +Imports flow inward only: + +``` +presentation ──→ application ──→ domain +infrastructure ─→ application ──→ domain +``` + +`presentation` never imports `infrastructure` directly. Concretes are wired through `Depends` against the abstractions in `application/`. + +--- + +## 2. The four layers + +| Layer | Responsibility | Imports allowed | +|---|---|---| +| `domain/` | The business model. Entities, value objects, business exceptions. **Pure Python, deterministic.** No third-party libs. No `datetime.now()` / `uuid.uuid4()` / `random.*` — use the abstractions instead. | Python stdlib types only (and other `domain/` modules). | +| `application/` | Orchestration. Use cases composing collaborators to satisfy a request. Defines the **abstractions** (`Clock`, `IdGenerator`, `Repository`, `EventBus`, …) that infrastructure implements. | `domain/` only. | +| `infrastructure/` | Concretes. SQLAlchemy bindings, structlog, OpenTelemetry, HTTP clients, the DI container, configuration, the outbox relay. Translates between the world and the application abstractions. | `domain/`, `application/`, third-party libs. | +| `presentation/` | Delivery. FastAPI routes, Pydantic schemas, error handlers, middleware, health endpoints. Wires concrete dependencies through `Depends` against abstractions. | `application/`, `domain/` (types), third-party (FastAPI, Pydantic). **Never imports `infrastructure/` directly** — concretes flow in via the DI container. | + +The dependency rule: **every arrow points inward** (`presentation` → `application` → `domain`; `infrastructure` → `application` → `domain`). + +### The Protocol layer = business contract, not technical mapping + +A Protocol in `application/` is not a thin wrapper over a SQL table or an HTTP client. Its **method names describe business questions** ("is this name free for this owner?", "give me the unfinished item with this id"), and its **docstrings are the contract** that the persistence binding follows. Two consequences: + +- Adding a new method to a Repository Protocol is a deliberate act — done by the use case that needs the question answered, not pre-emptively. See §5 Repository method naming below. +- The persistence binding never invents methods — it implements exactly what the Protocol declares, exactly as the docstring describes. + +### Enforcement + +The architectural rules are not aspirational. They are checked mechanically: + +- `scripts/checks/no_third_party_in_domain.py` — pre-commit + Claude hook + CI +- `scripts/checks/no_naive_datetime.py` — same +- `ty` strict in `ci-quality.yml` — catches type-level layer violations +- Dependency rule expressed as Mermaid in `docs/architecture.md` and rationale in [ADR 0001](../../../docs/adr/0001-clean-architecture.md). + +--- + +## 3. Where does X go? + +| Thing | Layer | Folder | +|---|---|---| +| Business rule, invariant | `domain/` | `entities/` or `value_objects/` | +| Value object (Money, Email, Quantity, an ID type, ...) | `domain/` | `value_objects/` (frozen dataclass + `__post_init__`) | +| Entity with identity (User, Order, ...) | `domain/` | `entities/` (regular class, identity-based equality via `Entity[TId]`) | +| Business exception | `domain/` | `exceptions/` (subclass `DomainError`) | +| Use case (orchestration) | `application/` | `use_cases/` (1 file = 1 use case, `…UseCase` suffix, inherits `UseCase[TInput, TOutput]`, public `execute()` method) | +| Abstract repository | `application/` | `repositories/` (inherits `Repository[TEntity, TId]`) | +| `Clock`, `IdGenerator`, `RandomSource`, `EventBus`, `AuditLog` | `application/` | top-level module (`clock.py`, `id_generator.py`, …) | +| New cross-cutting abstraction (`EmailSender`, `Cache`, …) | `application/` | top-level module — but check `building-a-feature` § "Adding a new abstraction" first | +| `…Input` / `…Output` DTOs | `application/` | `dtos/` (frozen dataclass) | +| SQLAlchemy ORM model | `infrastructure/` | `persistence/models/` (suffix `…Model`) | +| Concrete repository | `infrastructure/` | `persistence/_repository.py` (prefix by mechanism: `SqlAlchemy…`) | +| HTTP client wrapping a SaaS | `infrastructure/` | `http_clients/.py` | +| OTel + structlog setup | `infrastructure/` | `observability/` | +| Pydantic `Settings` | `infrastructure/` | `config/settings.py` | +| DI container (factories) | `infrastructure/` | `container.py` | +| Outbox bus + model | `infrastructure/` | `outbox.py`, `persistence/models/outbox_event.py` | +| Background workers | `infrastructure/` | `jobs/.py` (e.g., `outbox_relay.py`) | +| FastAPI app factory | `presentation/` | `api/app.py` | +| Versioned router | `presentation/` | `api/v1/.py` | +| Pydantic request/response schema | `presentation/` | `api/schemas/` (suffix `…Schema`) | +| Middleware | `presentation/` | `api/middleware/` | +| `Depends` factory wiring the container | `presentation/` | `api/dependencies/` | +| `DomainError` → HTTP status mapping | `presentation/` | `api/error_handlers.py` (`ERROR_HTTP_MAPPING`) | +| Fake of an abstraction (test double) | `tests/fakes/` | `tests/fakes/.py` (or `tests/fakes/repositories/.py`) | +| Alembic revision | `alembic/versions/` | `_.py` (with non-empty `downgrade()`) | + +### Where does X NOT go? + +| Anti-pattern | Why | Where it should live instead | +|---|---|---| +| `import sqlalchemy` in `application/use_cases/` | leaks persistence into orchestration | repository abstraction in `application/repositories/`, concrete in `infrastructure/` | +| `import fastapi` in `application/` | leaks delivery into orchestration | use case takes a DTO; FastAPI lives in `presentation/` | +| `import pydantic` in `domain/` | third-party in domain | value objects use `@dataclass(frozen=True, slots=True)` | +| `datetime.now()` in `domain/application/` | non-determinism breaks tests | inject `Clock`, call `clock.now()` | +| `uuid.uuid4()` in `domain/application/` | same | inject `IdGenerator`, call `ids.new()` | +| `raise HTTPException(...)` in `application/` | mixes HTTP into orchestration | raise a `DomainError` subclass; map to HTTP in `presentation/api/error_handlers.py` | +| `from {pkg}.infrastructure...` in `presentation/` | bypasses DI | inject via `Depends(get_*)` from `presentation/api/dependencies/` | +| `logger.info(...)` in `domain/application/` | logging is an infrastructure concern | log at the boundary (`infrastructure/`, middleware, error handler) | +| `float` for monetary or precise quantities in `domain/` | precision loss | `decimal.Decimal` | +| `class UserManager`, `class UserHelper`, `class UserService` | meaningless suffixes | apply naming below — pick a role stereotype | + +--- + +## 4. The User reference feature + +The codebase ships a working SSO-backed user-provisioning flow that +threads all four layers end to end: + +- `GET /v1/users/me` — the only user endpoint. The handler depends on + `get_or_provision_user`, which chains JWT verification with + `EnsureUserExistsUseCase`. On the first authenticated request for a + JWT subject, a `User` row is created from the JWT claims (``email`` + + ``name``); subsequent calls return the existing row. + +**Read these files** before adding your first feature — copy the +pattern (there is intentionally NO `POST /v1/users` — provisioning +happens lazily via SSO): + +| Layer | File | +|---|---| +| domain | `domain/value_objects/email.py`, `domain/value_objects/user_id.py` | +| domain | `domain/entities/user.py` (carries the `subject` field — IdP `sub` claim) | +| domain | `domain/exceptions/user.py` (`UserAlreadyExistsError`, `UserNotFoundError`) | +| domain | `domain/exceptions/auth.py` (`MissingProfileClaimsError` — JWT lacks email/name) | +| application | `application/auth/current_user.py` (`CurrentUser` VO, JWT claims projection) | +| application | `application/repositories/user.py` (Protocol — `find_by_subject` is the primary lookup) | +| application | `application/dtos/user.py` (`EnsureUserExistsInput` / `EnsureUserExistsOutput`) | +| application | `application/use_cases/ensure_user_exists.py` (idempotent find-or-create) | +| infrastructure | `infrastructure/persistence/models/user.py` (`UserModel` — `subject` UNIQUE) | +| infrastructure | `infrastructure/persistence/user_repository.py` (`SqlAlchemyUserRepository`) | +| infrastructure | `infrastructure/auth/jwt_verifier.py` (HS256/RS256, JWKS) | +| infrastructure | `infrastructure/container.py` (`build_user_repository`, `build_ensure_user_exists_use_case`) | +| presentation | `presentation/api/schemas/user.py` (`UserReadSchema`) | +| presentation | `presentation/api/dependencies/users.py` (`get_or_provision_user` chain) | +| presentation | `presentation/api/dependencies/auth.py` (`HTTPBearer` / `OAuth2AuthorizationCodeBearer` scheme, role checks) | +| presentation | `presentation/api/v1/users.py` (`GET /v1/users/me` router) | +| presentation | `presentation/api/error_handlers.py` (the `ERROR_HTTP_MAPPING` entries) | +| tests | `tests/unit/domain/test_user.py` + `test_email.py` | +| tests | `tests/unit/use_cases/test_ensure_user_exists.py` | +| tests | `tests/integration/infrastructure/test_user_repository.py` | +| tests | `tests/e2e/api/test_users_me_endpoint.py` | +| tests | `tests/fakes/repositories/user.py` (`InMemoryUserRepository`) + `tests/fakes/auth.py` (`FakeTokenVerifier`) | + +Migration: `alembic/versions/0001_init.py` (initial schema — creates `users` +alongside `outbox_events` and `idempotency_records`; `users.subject` is UNIQUE). + +If the project does not need a user notion at all, remove the feature +cleanly (entity, use case, repo binding, route, schema, migration, +tests). The auth chain (`get_current_user`, `require_roles`) is +independent and stays useful for projects that protect routes without +mapping to a persisted `User` row. + +--- + +## 5. Naming conventions (Uncle Bob strict) + +### Universal rules + +- **Intention-revealing.** No abbreviations: `usr`, `cnt`, `tmp`, `repo` (in identifiers), `cfg`, `mgr`. Exceptions: well-known short forms (`db`, `id`, `url`, `http`). +- **No type encodings.** No `I` prefix on interfaces. No Hungarian (`strName`). No `m_` prefix. +- **One word per concept** — pick a verb and stick with it project-wide. +- **Booleans**: `is_*`, `has_*`, `can_*`. Never negative (`is_not_empty` → negate `is_empty` at the call site). +- **Names should not lie.** A field called `users` returns a `list[User]`, not a `dict[UserId, User]`. A method `get_user` returns one user, not raises if missing — `find_user` is the one that may return `None`. + +### Verb cheat sheet (one word per concept) + +| Concept | Verb | Counter-examples (avoid mixing) | +|---|---|---| +| Local lookup, must succeed | `get_` | `fetch_user`, `retrieve_user` | +| Lookup that may miss | `find_` | `lookup_user`, `try_get_user` | +| I/O-bound load (DB, HTTP) | `load_` | `read_user_from_db` | +| Add a new entity to a collection / store | `add()` | `insert_user`, `register_user` (when "register" carries no business meaning) | +| Domain registration with a process | `register_` | use only when registration is a real domain step | +| Create-and-return a fresh instance | `create_` | use cases follow this for `CreateOrderUseCase`, `CreateTodoListUseCase`, … | +| Remove without history | `delete_` | | +| Mark inactive while keeping the row | `archive_` / `deactivate_` | | +| Pull collection by criterion | `find_s_by_` | not `get_users_where`, not `list_users_for` | +| Test exists | `_exists(...)` returning `bool` | not `has_user` if it implies ownership | + +### Banned generic class names + +`Manager`, `Processor`, `Handler` (except framework types like `RequestHandler`), `Service` (everything is a service in Clean Architecture — too generic), `Helper`, `Util`, `Data`, `Info`, `Common`, `Misc`, `Worker` (allowed only when paired with a job runner: `OutboxRelayWorker`). + +If you find yourself reaching for one of these, the actual role is hiding under it — pick a stereotype below. + +### Role-stereotype whitelist + +When naming a **Protocol** (the application-layer contract), prefer one of these suffixes — they replace the `Port` suffix: + +| Stereotype | Use | +|---|---| +| `…Repository` | Persistence of an entity (CRUD + queries) | +| `…Gateway` | Façade over an external SaaS (Stripe, SendGrid, …) | +| `…Bus` | Pub/sub or message routing | +| `…Sender` / `…Publisher` / `…Subscriber` | Outbound / inbound message roles | +| `…Generator` | Produces values (often unique) | +| `…Source` | Pure data provider (random, config, time series) | +| `…Provider` | Lazy resource provider | +| `…Reader` / `…Writer` | I/O over a medium | +| `…Factory` | Builds complex objects | +| `…Strategy` | Interchangeable algorithm | +| `…Validator` | Input validation | +| `…Cache` | Cache storage | +| `…Log` | Writes structured log entries | +| `Clock` / `Lock` / `Mutex` | System primitives (single word) | + +`…Port` is the **last-resort** suffix when none of the above fits. Document why in the docstring. + +### Concrete implementations + +Prefix by mechanism. Reads as "this is the X-flavoured implementation of role Y": + +| Abstract role | Production binding | Test Fake | +|---|---|---| +| `UserRepository` | `SqlAlchemyUserRepository` | `InMemoryUserRepository` | +| `Clock` | `SystemClock` | `FrozenClock` | +| `IdGenerator` | `Uuid4IdGenerator` | `SequentialIdGenerator` | +| `RandomSource` | `SystemRandomSource` | `SeededRandomSource` | +| `EmailSender` | `SendGridEmailSender` | `InMemoryEmailSender` | +| `EventBus` | `SqlOutboxEventBus` | `InMemoryEventBus` (when written) | + +### Suffixes that ARE kept on purpose + +- **Use cases** — `…UseCase` (`EnsureUserExistsUseCase`, `CreateOrderUseCase` — never bare `CreateOrder`). Deliberate role marker, makes the layer obvious. +- **Errors** — `…Error` (`UserAlreadyExistsError`). `…Exception` only on stdlib subclasses. +- **Pydantic schemas (presentation)** — `…Schema` (`UserCreateSchema`, `ErrorResponseSchema`). +- **SQLAlchemy models (infrastructure)** — `…Model` (`UserModel`). Disambiguates from the domain `User`. + +### Repository method naming — methods are business questions + +The base `Repository[TEntity, TId]` Protocol ships only the two truly generic methods: `add(entity)` and `find_by_id(id)`. Every other method on a sub-Protocol (e.g. `TodoListRepository`, `UserRepository`) is **added by the use case that needs it**, and its **name must describe a business question, not a SQL operation**. The docstring is the contract — it explains the filter semantics so the persistence binding can implement the right `WHERE` without guessing. + +#### Anti-pattern table + +| ❌ Tech-flat | ✅ Business-shaped | Why | +|---|---|---| +| `get_by_owner(owner_id)` | `list_owned_by(owner_id)` | Verb says "give me all", and `list_*` signals a collection result, not a single row | +| `get_by_id_and_owner(id, owner_id)` | `find_owned_by(owner_id, id)` | The "owned-by" check is the business intent, not a `WHERE owner_id = ? AND id = ?` filter | +| `get_by_name(name)` | `find_active_by_owner_and_name(owner_id, name)` | The active filter is part of the contract (excludes soft-deleted); also scopes by owner because globally unique names rarely make sense | +| `update_email(id, new_email)` | `record_email_change(user, new_email)` | The operation has a business meaning (audit trail, event emission) that the method name should evoke | +| `delete(id)` | `archive(id)` (soft) or `purge(id)` (hard) | Distinguishes two real domain operations under one CRUD verb | +| `check_exists(email)` | `is_email_taken(email)` | Question business, not technical check | +| `count(filter)` | `count_pending_for_owner(owner_id)` | Names the subset being counted; readers don't need to inspect the filter | +| `find_recent(limit)` | `find_recently_active_by_owner(owner_id, since)` | "Recent" without a window is undefined; "by_owner" makes the scope explicit | + +#### The contract = the docstring + +The Protocol's docstring on each method is the **only** brief the persistence binding has. It must cover: + +- What the method returns when the row doesn't exist (`None` vs. empty list vs. raise) +- Which rows are filtered out (soft-deleted? archived? draft state?) +- Whether the result is ordered (and by what) +- Which other Protocol method, if any, this one complements + +Example of a complete contract: + +```python +class TodoListRepository(Repository[TodoList, TodoListId], Protocol): + """Persistence contract for the TodoList aggregate. + + Methods carry the ubiquitous language of TodoList ownership and lifecycle. + """ + + async def find_active_by_owner_and_name( + self, owner_id: UserId, name: str + ) -> TodoList | None: + """Return the non-deleted list named ``name`` owned by ``owner_id``. + + Used by CreateTodoListUseCase for the uniqueness check before creation. + Soft-deleted lists are ignored — re-creating a list with the same name + after deletion is permitted. Returns ``None`` when no active list matches. + """ + ... +``` + +If a use case needs `find_by_id` (without a state filter), use the inherited one. The moment the question carries a state filter ("the active one with this id"), prefer a named method. + +#### Bad smell: a Repository that looks like an ORM + +If the Repo has methods like `find_by_X(value)` for every field, you've recreated a generic `Repository[Entity].query(field=value)`. That's a sign the use cases are too thin. Push business intent INTO the Repo method names. + +### Variables and function arguments + +- `email`, not `email_address` — context already says it's an address. +- `user`, not `user_object` — `_object` is noise. +- `user_id` is good; `id` alone is fine inside a class method when the type is clear. +- Plural for collections: `users: list[User]`, never `user_list`. +- Use the **Protocol** type for parameters, the **concrete** for locals when the difference matters: `def create(self, repo: UserRepository) -> None:` but `repo = SqlAlchemyUserRepository(session)`. + +### File and folder names + +- Snake_case Python files. +- **Plural** for collections: `use_cases/`, `entities/`, `repositories/`, `value_objects/`, `dtos/`. +- **Singular** for concept modules: `clock.py`, `container.py`, `app.py`, `outbox.py`. + +#### Group by aggregate when the kind folder has 3+ files for it + +Each Clean-Architecture layer is split first by **kind** (use_cases, entities, value_objects, dtos, repositories, schemas, models, …) — that part is non-negotiable. When the same **aggregate** has **3 or more** files inside one kind folder, group them into a sub-folder named after the aggregate (snake_case), and **drop the aggregate prefix from the filenames inside**. + +``` +application/use_cases/ + ensure_user_exists.py # 1 use case for User → flat + todo_list/ # 4 use cases for TodoList → grouped + __init__.py + create.py + get.py + list.py + delete.py + +domain/value_objects/ + email.py + user_id.py + todo_list/ + __init__.py + name.py + description.py + todo_list_id.py + +application/dtos/ + user.py # one DTO module → flat + todo_list/ # 3+ DTOs → grouped + create_input.py + update_input.py + view.py + +presentation/api/schemas/ + user.py + todo_list/ + create_request.py + update_request.py + view_response.py +``` + +Where it usually does **not** apply (one file per aggregate is the norm): + +- `domain/entities/.py` — one entity per aggregate. +- `application/repositories/.py` — one Protocol per aggregate. +- `presentation/api/v1/s.py` — one router per aggregate. +- `infrastructure/persistence/models/.py` — one ORM model per aggregate. + +Thresholds: + +- **< 3 files** for the aggregate in that kind → keep them flat with the aggregate in the filename (`create_user.py`). +- **≥ 3 files** → create the sub-folder. Promote existing files into it in the same edit (no half-state). +- The threshold is per kind folder, not project-wide. `domain/value_objects/todo_list/` can exist while `application/use_cases/` keeps `todo_list/` flat if there are only 2 use cases. + +Aggregate sub-folders ship an `__init__.py`. Names inside follow the **verb-only** convention (`create.py`, `get.py`) so the import reads `from .use_cases.todo_list import create` — the aggregate is carried by the folder. + +### Tests + +- File: `test_.py` mirroring the source layout (`tests/unit/use_cases/test_ensure_user_exists.py`). +- Function: `test_given__when__then_`. +- Body format (GIVEN / WHEN / THEN comments): see `tdd-workflow`. + +### Constants + +- `SCREAMING_SNAKE_CASE` for module-level constants (`DEFAULT_RETENTION_DAYS = 30`). +- Group related constants in a `constants.py` per module (`presentation/api/constants.py`). +- Class-level constants (a `ClassVar`) when they belong to one type — e.g. `DomainError.code`. +- Magic numbers in code are flagged by `ruff PLR2004`. Lift them to a named constant. + +--- + +## 6. The DI container + +The container is **pure factory functions** in `infrastructure/container.py`. No DI framework, no magic — just functions that build concrete instances. FastAPI `Depends` wraps each factory in `presentation/api/dependencies/`. Tests override at the `Depends` layer with `app.dependency_overrides`. + +This is the **only** seam between `application/` (abstractions) and `infrastructure/` (concretes). Anywhere else, importing from `infrastructure/` in `presentation/` is a layer violation. + +### Factories shipped + +| Factory | Returns | +|---|---| +| `build_clock()` | `Clock` (production: `SystemClock`) | +| `build_id_generator()` | `IdGenerator` (`Uuid4IdGenerator`) | +| `build_random_source()` | `RandomSource` (`SystemRandomSource`) | +| `build_database(settings)` | `(AsyncEngine, async_sessionmaker)` | +| `build_user_repository(session)` | `UserRepository` (`SqlAlchemyUserRepository`) | +| `build_event_bus(session)` | `EventBus` (`SqlOutboxEventBus`) | +| `build_ensure_user_exists_use_case(*, users, clock, ids, events)` | `EnsureUserExistsUseCase` wrapped with `_instrumented` | + +Matching `Depends` in `presentation/api/dependencies/`: `get_settings_dependency`, `get_session`, `get_clock`, `get_id_generator`, `get_random_source` (in `common.py`); `get_user_repository`, `get_ensure_user_exists_use_case`, `get_or_provision_user` (in `users.py`); `get_current_user`, `require_roles`, the security scheme (in `auth.py`). + +### Adding a new factory + +`infrastructure/container.py`: + +```python +def build__repository(session: AsyncSession) -> Repository: + return SqlAlchemyRepository(session) + + +def build___use_case( + *, + s: Repository, + clock: Clock, + ids: IdGenerator, +) -> UseCase: + use_case = UseCase(s=s, clock=clock, ids=ids) + return _instrumented(use_case, span_name="_") +``` + +Every use case factory **must** wrap with `_instrumented(span_name=...)`. The wrapper: + +- opens an OTel span around `execute` +- logs `DomainError` raises at `WARNING` with `code` + `context` +- logs unexpected exceptions at `ERROR` with the full stack trace +- re-raises in all cases (the presentation layer maps the exception to HTTP) + +This is **the** reason `application/` never imports structlog or OpenTelemetry: the wrapper bolts cross-cutting concerns on from the outside. + +Factory rules: + +- **Pure** — no I/O, no globals (settings flow in as a parameter when needed). +- **Synchronous** unless it has to be async — only `get_session` is async. +- **Composable** — a use case factory takes the repo factory's output as a typed parameter, not a raw `AsyncSession`. The `Depends` wrapper handles the chain. + +### Adding the `Depends` wrapper + +`presentation/api/dependencies/.py`: + +```python +def get__repository( + session: AsyncSession = Depends(get_session), +) -> Repository: + return build__repository(session) + + +def get___use_case( + s: Repository = Depends(get__repository), + clock: Clock = Depends(get_clock), + ids: IdGenerator = Depends(get_id_generator), +) -> UseCase: + return build___use_case(s=s, clock=clock, ids=ids) +``` + +### Tests override at the `Depends` layer + +The e2e fixture in `tests/e2e/conftest.py::app` already pre-wires the in-memory Fakes for User. Most e2e tests need no override. For custom ones: + +```python +async def failing_session() -> AsyncIterator[AsyncSession]: + yield StubAsyncSession(raises=OperationalError("db down", params=None, orig=None)) + + +def test_given_db_unreachable_when_calling_ready_then_returns_503(app, client) -> None: + # GIVEN + app.dependency_overrides[get_session] = failing_session + + # WHEN + response = client.get("/health/ready") + + # THEN + assert response.status_code == 503 +``` + +Override the `Depends` you want to swap, leave everything else inherited from the fixture. + +### Anti-patterns + +| Wrong | Right | +|---|---| +| `from {pkg}.infrastructure.persistence.user_repository import SqlAlchemyUserRepository` in a route | `Depends(get_user_repository)` | +| `Depends(SqlAlchemyUserRepository)` directly | depend on `get_user_repository` | +| `build_*_use_case` returns the use case **without** `_instrumented(...)` | always wrap; observability stays out of `application/` | +| Use case takes `Settings` as a constructor parameter | factories read from `Settings`, the use case takes the abstractions only | +| Test mutates a fixture in place across tests | overrides go on the per-test `app.dependency_overrides` | +| `Depends(lambda: build_user_repository(...))` inline in a route | declare a `get_user_repository` in `presentation/api/dependencies/` | + +--- + +## 7. Observability patterns + +Four observability surfaces, each with strict rules about where it can appear. + +### 7.1 Structured logs (structlog) + +#### Where you can log + +| Layer | Allowed? | Why | +|---|---|---| +| `domain/` | NO | The domain is silent. Surface state changes via typed exceptions. | +| `application/` | NO | Use cases stay pure for testability. Cross-cutting log lines are added by `_instrumented`. | +| `infrastructure/` | YES | Boundary I/O is logged where it happens. | +| `presentation/` | YES | Error handlers and request middleware emit access logs and error events. | + +If a use case feels like it "needs a log", the right answer is almost always: + +- a typed `DomainError` (business event) — the wrapper logs it at WARNING automatically. +- an `AuditLog.record(...)` call (user-facing event) — see `application/audit_log.py`. + +#### Logger API + +```python +import structlog + +logger = structlog.get_logger(__name__) + +logger.info("user.created", user_id=str(user.id), email=str(user.email)) +logger.warning("payment.declined", reason=reason, attempt=attempt) +logger.exception("unexpected_error", path=path) +``` + +- **Event-style.** First positional arg is a dot-separated event name (`.`), same convention as domain events. +- **Structured kwargs, never f-strings.** The renderer JSON-encodes in prod, pretty-prints in dev. +- **Sensitive keys redacted automatically.** The `_redact_secrets` processor replaces any key matching `password`, `token`, `authorization`, `secret`, `api_key` (case-insensitive substring) with `***`. + +#### Format switch — dev vs prod + +`LOG_FORMAT` env var: + +| Value | Renderer | Use | +|---|---|---| +| `console` (default in dev) | `structlog.dev.ConsoleRenderer` with colours | terminal-readable | +| `json` (default in prod) | `structlog.processors.JSONRenderer` | one JSON line per event | + +### 7.2 Tracing (OpenTelemetry) + +#### Auto-instrumentation, shipped + +The `configure_tracing` call at app startup wires: + +- `opentelemetry-instrumentation-fastapi` — one span per HTTP request, W3C TraceContext propagation +- `opentelemetry-instrumentation-sqlalchemy` — DB spans +- `opentelemetry-instrumentation-asyncpg` — connection-level spans +- `opentelemetry-instrumentation-httpx` — outgoing HTTP calls + trace headers propagation +- `opentelemetry-instrumentation-logging` — `trace_id` and `span_id` injected into structlog events + +No code in `application/` or `domain/` touches OTel. + +#### Use case spans — via the container, not a decorator + +Every use case built by the container goes through `_instrumented(use_case, span_name="...")`. **Do NOT** put `@traced` on the use case class — that would force `application/` to import from `infrastructure/observability/`, a layer violation. + +#### Custom spans inside infrastructure + +`@traced` is fine **inside `infrastructure/`**: + +```python +from {{ cookiecutter.package_name }}.infrastructure.observability import traced + + +@traced("user_repository.find_inactive") +async def find_inactive(self, before: datetime) -> list[User]: + ... +``` + +Add attributes via `trace.get_current_span().set_attribute(...)` only when the value is **bounded and meaningful** — do not dump full payloads. + +#### Exporter switching + +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Behaviour | +|---|---| +| unset | spans go to stdout via `ConsoleSpanExporter` — dev default, CI default | +| set to a URL | OTLP gRPC export to the configured collector | + +The devcontainer ships an OTel collector at `http://otel-collector:4317` that forwards to Jaeger; UI on `http://localhost:16686`. + +### 7.3 Metrics (Prometheus) + +`prometheus-fastapi-instrumentator` exposes `/metrics` (not in the OpenAPI schema). Out of the box: + +- HTTP request count + duration histogram + in-progress gauge, per `(method, handler, status)` +- Process metrics (CPU, memory, fds, uptime) + +`/metrics` and `/health/*` are excluded from instrumentation. The Helm chart ships a `ServiceMonitor` (gated by `metrics.enabled`). + +Custom metrics go in `infrastructure/observability/metrics.py`. Define counters / histograms at module load, label them by low-cardinality tuples, increment from infrastructure code. + +### 7.4 Request correlation + +Every HTTP request gets `X-Request-ID`: + +- If the caller sent one, it is preserved. +- Otherwise `RequestIdMiddleware` generates a UUID4 hex. + +The id is bound to the structlog context, so every log line during the request carries `request_id=`. The response echoes it back. + +`AccessLogMiddleware` emits one `http.request` event per non-health-check request with method, path, status, duration_ms, client. + +### Observability anti-patterns + +| Wrong | Right | +|---|---| +| `print(...)` | use structlog; `print` is caught by ruff `T20` | +| `logging.getLogger(__name__).info(...)` | `structlog.get_logger(__name__).info(...)` | +| `logger.info(f"user {user.id} created")` | `logger.info("user.created", user_id=str(user.id))` | +| Logging inside a use case "for visibility" | raise a typed `DomainError` or call `AuditLog.record(...)` | +| `@traced` on a use case class | wrap in the container with `_instrumented` | +| Span attribute with the full request payload | bounded, low-cardinality values only | + +--- + +## 8. Hard rules digest + +1. **TDD red first.** Every line of production code is justified by a failing test. See `tdd-workflow`. +2. **No mocks in unit tests** — only Fakes from `tests/fakes/`. +3. **No third-party imports in `src//domain/`** — not even `datetime.now()`, `uuid.uuid4()`, `random.*`. +4. **No log in `domain/` or `application/`**. +5. **No HTTP types in `application/` or `domain/`** — exceptions are `DomainError` subclasses. +6. **GIVEN/WHEN/THEN strict** in unit and integration tests. +7. **Conventional commits**, no `--no-verify`, no force push. +8. **Function ≤ 25 lines.** Cyclomatic complexity ≤ 10. +9. **`Decimal` for money**, **TZ-aware datetimes** stored UTC. +10. **No `Manager`, `Helper`, `Util`, `Service`** generic class names. + +--- + +## 9. Five commands you need first + +The project uses `just` as command runner — pre-installed in the devcontainer. Type `just` to list every recipe. + +```bash +just install # uv sync --group dev +just dev # FastAPI with auto-reload +just test # fast unit tests (-x -q) +just test-all # unit + integration + e2e (Docker required for integration) +just mutate # mutation testing on domain + application +``` + +--- + +## 10. Where to look next + +- **`CLAUDE.md`** at the repo root — the routing table from prompts to skills (auto-loaded every session). +- **`docs/architecture.md`** — Mermaid diagrams of the layers + a request sequence diagram. +- **`docs/adr/`** — ADRs documenting the structural choices (clean architecture, TDD + mutation, fakes-only, transaction boundary, outbox). +- **`docs/runbooks/`** — operator playbooks: `backups-and-restore.md`, `disaster-recovery.md`, `verification-log.md`. Open these before debugging an incident. +- **`.claude/skills/`** — other skills: + - `tdd-workflow` — outside-in TDD, Fakes, GIVEN/WHEN/THEN, mutmut + - `building-a-feature` — end-to-end runbook (plan → use case → persistence → route → wiring) + - `writing-domain-code` — domain purity + exceptions + - `database-and-migrations` — Alembic, schema changes + - `adding-auth` — JWT + role guard + - `event-sourcing-pattern` — when to ES, how to bootstrap + - `writing-a-helm-change` — chart edits + lint + - `reviewing-a-pr-soma-style` — PR review checklist diff --git a/{{cookiecutter.project_slug}}/.claude/skills/reviewing-a-pr-soma-style/SKILL.md b/{{cookiecutter.project_slug}}/.claude/skills/reviewing-a-pr-soma-style/SKILL.md new file mode 100644 index 0000000..07c176c --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/skills/reviewing-a-pr-soma-style/SKILL.md @@ -0,0 +1,88 @@ +--- +name: reviewing-a-pr-soma-style +description: SOMA review checklist plus tone guidance. What to look for in architecture, tests, migrations, helm, supply chain — and how to phrase the feedback. +when_to_use: User asks for a review; about to comment on a PR; running a final sanity pass on your own diff before requesting human review. +--- + +# Reviewing a PR — SOMA style + +## Tone + +* **Be precise.** Quote the file + line; do not write "this is wrong somewhere". +* **Cite the rule.** Link the skill or ADR that explains why ("`writing-domain-code` forbids `datetime.now()` here"). +* **Distinguish blocker vs suggestion.** Use "MUST" for blockers tied to a hard rule, "SHOULD" for opinionated improvements, "nit" for cosmetic. Authors triage faster. +* **Propose the fix.** A review that points at a problem without a direction is annoying; propose the smallest diff that resolves it. +* **Confirm what works.** Mention one thing the PR got right — keeps the loop sustainable. + +## Architecture (every PR) + +* [ ] No third-party imports under `src//domain/`. +* [ ] No `datetime.now()`, `uuid.uuid4()`, `random.*`, `time.time()` in `domain/` or `application/` — `Clock` / `IdGenerator` / `RandomSource` used instead. +* [ ] No `float` for monetary or precise quantities in `domain/`. +* [ ] No `logger.*` or `print` inside `domain/` or `application/`. Business events flow through typed `DomainError` / `AuditLog`. +* [ ] No `infrastructure/...` import from `presentation/...`. Concretes flow in via `Depends` against the abstractions. +* [ ] No HTTP types (`HTTPException`, `Response`, …) in `application/`. +* [ ] Naming follows `onboarding-soma` § "Naming conventions" — no `Manager` / `Helper` / `Service` / `Util`; role stereotype used; `Error` for exceptions. + +## Base classes (when new code is added) + +* [ ] New entity inherits `Entity[Id]` (identity equality, no manual `__eq__`). +* [ ] New value object inherits `ValueObject` + `@dataclass(frozen=True, slots=True)`. +* [ ] New repository protocol extends `Repository[, Id]` and also declares `Protocol` in its bases (don't redeclare `add` / `find_by_id`). +* [ ] New use case inherits `UseCase[Input, Output]`. +* [ ] New `DomainError` subclass declares `code` (SCREAMING_SNAKE) and `default_message` as class attributes (they are `ClassVar` on the base). +* [ ] New `DomainError` subclass has an entry in `presentation/api/error_handlers.py::ERROR_HTTP_MAPPING` (the unit test will fail otherwise). +* [ ] New use case factory in `infrastructure/container.py` wraps with `_instrumented(use_case, span_name="...")` — no exception. + +## Abstractions (when a new one is introduced) + +* [ ] Author ran the `building-a-feature` § "Adding a new abstraction" table — no existing abstraction covers this need. +* [ ] Class name uses a role stereotype (`…Sender`, `…Gateway`, `…Cache`, …), not `Port`. +* [ ] Abstract lives in `application/`; production binding in `infrastructure/`; Fake in `tests/fakes/`; conftest fixture registered if reused. + +## Tests + +* [ ] Unit tests cover new branches in `domain/` and `application/`. Fakes only — no `unittest.mock`, no `Mock`, no `patch` in `tests/unit/`. +* [ ] GIVEN / WHEN / THEN format on both name and body. The `test_naming.py` lint will catch most cases but skim manually. +* [ ] If a new repository is introduced, `tests/integration/infrastructure/test__repository.py` exists with at least: happy add+find, IntegrityError → DomainError, missing → None. +* [ ] E2E: 1 happy + 1 critical error per endpoint. No duplication of use case branches at e2e level. +* [ ] `just mutate` was run on the touched `domain/` + `application/` paths; CI floor is 90%. + +## Events (when a use case starts emitting) + +* [ ] Event name follows `.` dotted lowercase (`user.created`, `order.shipped`). +* [ ] Use case takes `EventBus` as a constructor parameter, not the SQL binding directly. +* [ ] Container factory + `Depends` wrapper updated to chain `EventBus` through `get_event_bus`. +* [ ] Payload is a flat dict of stable types (str / int / iso datetime). No nested objects that consumers cannot read. + +## Migrations + +* [ ] The autogenerated revision was read and curated. No `op.drop_*` slipped in without intent. +* [ ] `downgrade()` is non-empty and the round trip (`just migrate → just migrate-down → just migrate`) was tested. +* [ ] NOT NULL on a column added to a non-empty table is paired with a `server_default`, or split into three migrations. +* [ ] `ci-migration-drift` is green (no autogen diff against `main` models). + +## Helm (when `helm/` changed) + +* [ ] Field present in **both** `values.yaml` and `values.schema.json` — Helm silently drops unknown overrides in some versions. +* [ ] Each environment overlay (`values-dev/staging/prod.yaml`) updated when relevant. +* [ ] `terminationGracePeriodSeconds` ≥ uvicorn `--timeout-graceful-shutdown` (currently 30 → at least 35 in values). +* [ ] `helm lint`, `kubeconform -strict`, `polaris audit` clean locally. + +## Supply chain & ops + +* [ ] No secret in code, no `.env` committed, no API key in a test fixture. +* [ ] Conventional Commits messages (commitizen pre-commit hook validates). +* [ ] PR size manageable. Beyond ~500 LOC, justify in the description or split. +* [ ] CI green: `ci-quality`, `ci-security`, `ci-mutation`, `ci-helm` (if helm/ changed), `ci-migration-drift` (if persistence/ or alembic/ changed), `ci-image-scan`, `sdk-typescript` (if frontend_sdk=typescript). + +## Useful one-liners + +```bash +git diff main...HEAD --name-only # files in the PR +git diff main...HEAD -- 'src/*/domain/**' # zoom on the domain layer +just lint # ruff + ty + 4 architectural lints + format +just test-all # unit + integration + e2e +just mutate # mutation testing on domain + application +just helm-lint +``` diff --git a/{{cookiecutter.project_slug}}/.claude/skills/tdd-workflow/SKILL.md b/{{cookiecutter.project_slug}}/.claude/skills/tdd-workflow/SKILL.md new file mode 100644 index 0000000..752db9a --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/skills/tdd-workflow/SKILL.md @@ -0,0 +1,513 @@ +--- +name: tdd-workflow +description: Outside-in TDD + test pyramid + Fakes-only at unit + GIVEN/WHEN/THEN + writing a Fake + mutation testing. The single source of truth for everything that lives under tests/ and for the red-green-refactor loop. +when_to_use: Adding a feature, fixing a bug, writing or reviewing any test under tests/. Adding a new Fake under tests/fakes/. Designing the test suite for a new feature. +--- + +# TDD workflow — outside-in + +The discipline is **outside-in**: the first failing test you write describes the **feature behaviour**, not an implementation detail. The compiler then tells you what to build — domain pieces emerge as the test demands them, never preemptively. + +Cycle: +1. Red — outer feature test +2. Green — minimum to pass +3. Refactor under green +4. Add the next branch as a new red — repeat +5. When the use case is behaviourally complete: mutate + +--- + +## Where the first red goes + +| Change | First red test | +|---|---| +| New feature ("create a todolist", "complete a task") | `tests/unit/use_cases/test__.py` — outer test with Fakes for the use case | +| New rule on an existing entity ("a closed order cannot be shipped") | `tests/unit/use_cases/test__.py` — outer test exercising the rule via the use case that triggers it | +| Pure-domain invariant that does NOT involve a use case (rare — e.g. an `Email` rejects malformed strings) | `tests/unit/domain/test_.py` — inside-out drill, the value object alone | +| Bug fix | The test that REPRODUCES the bug. Usually at the level where the bug was observed. | + +Default for a feature is **outer at the use case level**. Don't drop to the entity level unless the rule is genuinely independent of any use case context. + +--- + +## 1. Red — write the outer test as if everything existed + +Write the test imagining that the use case, the DTO, the repository Protocol, the entity, the value object — ALL already exist. The compiler's import errors will guide the build order. + +```python +# tests/unit/use_cases/test__.py + +@pytest.mark.unit +async def test_given__when__then_() -> None: + # GIVEN + repo = InMemoryRepository() + use_case = UseCase(=..., ...) + + # WHEN + output = await use_case.execute(Input(...)) + + # THEN + assert +``` + +Run the test. Expected first reaction: **ImportError or NameError**. That's correct — that import is the next class to build. + +--- + +## 2. Green — let the imports drive the build order + +Build the missing classes in the order the failures expose them. For each one, write the MINIMUM surface that turns the next failure green: + +1. `UseCase` + `Input` + `Output` (probably empty bodies / placeholder fields). +2. `Repository` Protocol + `InMemoryRepository` Fake (the Fake is what the test passes in; the Protocol is the type). +3. `` domain entity — minimum fields the test references. +4. `Id` value object (or any other typed wrapper the entity needs). +5. Wire the use case body until the assertions pass. + +The principle: **the test stays the only specification**. Don't add a `priority` field on the entity if no test mentions priority. Don't add a `find_by_email` Protocol method if the current test doesn't need it. + +Re-run the test between each step. Eventually it goes green. + +--- + +## 3. Refactor under green + +While the bar is green: rename, extract, inline, deduplicate. After each change, re-run the test. A red bar during refactor means **undo**, not push through. + +--- + +## 4. Add the next branch — back to red + +Each new business rule is a NEW outer test, which forces the next domain piece to emerge: + +- "Refuses duplicates per owner" → new test → adds `find_active_by_owner_and_name(...)` to the Protocol + a `AlreadyExistsError` domain exception. +- "Emits `.` after creation" → new test asserting `event_bus.published` → wires `await events.publish(...)` in the use case. +- "Refuses an invalid ``" → new test → adds the validation to the value object or entity factory. + +NEVER pre-build a Protocol method or an entity field that no test demands. + +--- + +## 5. Mutate — let mutmut grade the suite + +When the use case is behaviourally complete: + +```bash +just mutate # mutmut on domain + application +just mutate-results # latest report +``` + +Each surviving mutant is a free hole. For each one: + +1. Read the mutation that survived. +2. Write a new failing outer test (or a focused inner test if the mutation is inside an entity method) that would die against that mutation. +3. Re-run the green-red-green cycle. + +CI floor: 90% kill rate on `ci-mutation.yml`. + +### Kill the obvious survivors before reading the report + +Mutmut routinely flags the same three patterns whenever a new use case +is wired up. Adding ~5 targeted tests **before** the first mutmut run +gets the suite to ~90% naturally and lets the report focus on the real +gaps. Pattern check-list for any new ``…UseCase``: + +1. **Boundary off-by-one on length / count checks.** For every + ``if len(x) > N`` (or ``>=``, ``<``, ``<=``), add one test at exactly + the boundary (``len(x) == N``) that confirms the OTHER side of the + inequality. Otherwise mutmut flips ``>`` to ``>=`` and survives. + + ```python + async def test_given_name_exactly_255_chars_when_creating_then_succeeds(...): + # WHEN length == NAME_MAX_LENGTH, the use case must accept it. + ``` + +2. **Context kwargs in ``raise DomainError(...)``.** Every + ``raise SomeError(key=value)`` mutates to ``raise SomeError(key=None)`` + without changing observable behaviour from the test's + ``pytest.raises``. Catch them by asserting on ``.context[key]``: + + ```python + with pytest.raises(InvalidTodoListNameError) as excinfo: + await use_case.execute(...) + assert excinfo.value.context["length"] == len(too_long_name) + ``` + + These mutations are semantically benign (the context only feeds the + structured log), but they count toward the kill rate. Choose + consistency: either assert systematically, or document that the + context kwargs are excluded from the kill-rate target — don't mix. + +3. **DTO Output fields.** Every ``return SomeOutput(field=value)`` + mutates each ``field=value`` to ``field=None``. Add **one named + test per field** (not several asserts in the same test — the + one-assertion-per-test rule still holds): + + ```python + async def test_given_creation_when_inspecting_output_then_id_matches_persisted(...): + output = await use_case.execute(...) + assert output.id == persisted.id.value + + async def test_given_creation_when_inspecting_output_then_name_matches_input(...): + output = await use_case.execute(...) + assert output.name == "Shopping" + + async def test_given_creation_when_inspecting_output_then_created_at_matches_clock(...): + output = await use_case.execute(...) + assert output.created_at == frozen_clock.now() + ``` + + Alternatively, one test that asserts the whole Output equals a + literal — that's still ONE assertion. Pick the form that produces + the most readable failure message for that field. + +Run mutmut after these are in place. Real holes (e.g., a missing branch +on the use case body) become much easier to spot in the report once +the routine survivors are gone. + +--- + +## Worked example — "create a todolist" (outside-in) + +### Red 1 — outer test, happy path + +```python +# tests/unit/use_cases/todo_list/test_create.py + +@pytest.mark.unit +async def test_given_authenticated_owner_when_creating_todolist_then_persists_and_returns_output( + create_todo_list_use_case: CreateTodoListUseCase, + todo_list_repository: InMemoryTodoListRepository, +) -> None: + # GIVEN + payload = CreateTodoListInput(name="Shopping", owner_id=alice_id) + + # WHEN + output = await create_todo_list_use_case.execute(payload) + + # THEN + assert output.name == "Shopping" + assert len(todo_list_repository.lists) == 1 +``` + +`just test` → `ImportError: cannot import CreateTodoListUseCase`. + +### Green 1 — build minimum + +In order, until the test goes green: +1. `application/dtos/todo_list.py` with `CreateTodoListInput`, `CreateTodoListOutput`. +2. `application/repositories/todo_list.py` with `class TodoListRepository(Repository[TodoList, TodoListId], Protocol): pass`. +3. `tests/fakes/repositories/todo_list.py` with `InMemoryTodoListRepository` storing `dict[TodoListId, TodoList]`. +4. `domain/value_objects/todo_list_id.py` with `TodoListId` (frozen dataclass around UUID). +5. `domain/entities/todo_list.py` with `TodoList(Entity[TodoListId])` minimum fields: `id`, `name`, `owner_id`. +6. `application/use_cases/create_todo_list.py` with `execute` that adds via repo and returns the output. + +`just test` → green. + +### Red 2 — refuses duplicates + +```python +async def test_given_existing_list_with_same_owner_and_name_when_creating_then_raises_already_exists( + create_todo_list_use_case, + todo_list_repository, +) -> None: + # GIVEN: already a list named "Shopping" for alice + ... + + # WHEN / THEN + with pytest.raises(TodoListAlreadyExistsError): + await create_todo_list_use_case.execute(CreateTodoListInput(name="Shopping", owner_id=alice_id)) +``` + +This adds: +- `TodoListAlreadyExistsError` (domain exception with `code = "TODO_LIST_ALREADY_EXISTS"`). +- `find_active_by_owner_and_name(owner_id, name) -> TodoList | None` on the Protocol AND the Fake. +- The check in the use case body. + +`just test` → green again. + +### Red 3 — emits `todo_list.created` + +```python +async def test_given_valid_input_when_creating_then_publishes_todo_list_created_event( + create_todo_list_use_case, + event_bus: InMemoryEventBus, +) -> None: + await create_todo_list_use_case.execute(CreateTodoListInput(...)) + name, payload = event_bus.published[0] + assert name == "todo_list.created" + assert payload["id"] == str() +``` + +This adds: +- `EventBus` collaborator on `CreateTodoListUseCase.__init__`. +- `await self._events.publish("todo_list.created", {...})` AFTER the write. + +`just test` → green. + +### Mutate + +`just mutate` flips the uniqueness check from `is None` to `is not None`, the event name from `todo_list.created` to `todo_list.creates`, etc. Each surviving mutant is a missing test. + +--- + +## Inside-out drill — for pure-domain invariants + +When a rule has no use case context — for example, `Email("not-an-email")` must raise — the test is at the value object level: + +```python +# tests/unit/domain/test_email.py +def test_given_malformed_input_when_constructing_email_then_raises_invalid_email() -> None: + with pytest.raises(InvalidEmailError): + Email("not-an-email") +``` + +Same red-green-refactor-mutate cycle, just no use case in scope. This is the inside-out path; it's rare. + +--- + +# Test layout and rules + +## Pyramid + +``` +tests/ +├── unit/ # Anything pure: no I/O, no DB, no HTTP server. Mutation testing target. +│ ├── use_cases/ # ★ PRIMARY citizen — every feature has its outer test here, with Fakes. +│ │ # Mirrors src//application/use_cases/. Aggregate grouping kicks +│ │ # in at 3+ tests for the same aggregate (tests/unit/use_cases//). +│ ├── domain/ # Inside-out drills on a single VO / entity invariant — secondary. +│ ├── application/ # Pure non-use-case helpers (e.g. CurrentUser.has_role). +│ ├── infrastructure/ # Pure helpers from infrastructure (e.g. backoff math). +│ └── presentation/ # Pure FastAPI dependency factories, exhaustive error mapping checks. +├── integration/ # testcontainers Postgres / Redis. Real systems. +│ └── infrastructure/ # repositories, http clients, mappers +└── e2e/ # FastAPI on TestClient + real adapters (testcontainers PG + + └── api/ # HS256 JWT verifier). Fakes are NOT used here — see §3. +``` + +Discriminator for **what belongs in `tests/unit/`** is "**is it pure?**" (no I/O, no DB, no HTTP server, no testcontainers), NOT "is it a use case". `tests/unit/use_cases/` is the primary citizen because that's where each feature's specification lives; everything else under `unit/` is a focused pure test that would otherwise need an e2e to be validated. + +Per-layer conftests under `tests/conftest.py` (root: Fakes for unit + `pg_url`/`pg_engine` for integration & e2e) + `tests/unit/conftest.py` + `tests/integration/conftest.py` (test-scoped `pg_session` with savepoint rollback) + `tests/e2e/conftest.py` (`app`, `client`, `e2e_session_factory`, `mint_token`, `jwt_verifier`, `StubAsyncSession`). + +## Hard rules + +### 0. Outer drives inner + +For a new feature, the **first** failing test is at the use case level (`tests/unit/use_cases/`) with Fakes. Domain-level tests (`tests/unit/domain/`) are added afterwards, only for invariants that the use case test cannot express well on its own. The use case test is the **specification**; the domain test is **drill**. Never the opposite order. + +### 1. Fakes-only at unit + +Pass an in-memory implementation from `tests/fakes/` to the system under test. **Forbidden** in `tests/unit/`: `unittest.mock`, `Mock`, `MagicMock`, `Spy`, `Stub`, `patch`. + +Rationale: a Mock asserts *which* method got called with *which* arguments — that couples the test to the implementation. A Fake asserts that *observable behaviour* happened — the test stays valid through any refactor that preserves behaviour. + +If you need to verify a side-effect, the Fake exposes inspectable state. The canonical example is `tests.fakes.repositories.user.InMemoryUserRepository.users` — a dict you read directly to assert "this user was persisted". + +### 2. Real systems at integration + +Use `testcontainers` (Postgres, Redis) to run actual instances. The `pg_session` fixture in `tests/integration/conftest.py` spawns Postgres 17 once per test session and yields a transaction-rolled-back session per test. **Mock only** what you genuinely cannot self-host (a paid SaaS sandbox API). + +Integration tests prove the contract that unit-level Fakes cannot: +- INSERT propagates the unique constraint as a `DomainError` +- `find_by_email` round-trips the entity ↔ ORM model mapping +- the transaction rollback semantics actually rollback + +### 3. E2E proves wiring on REAL adapters, not branches + +For each endpoint: **1 happy path + 1 critical error**. No more. + +If a use case has 12 branches, those are 12 unit tests; only 1-2 e2e. Duplicating branches at e2e level is a smell — the right fix is adding a unit test, not adding an e2e. + +E2E uses the `client` fixture from `tests/e2e/conftest.py`, which wires the FastAPI app to **real adapters** by default — real `SqlAlchemyUserRepository`, real `SqlOutboxEventBus`, real `JwtTokenVerifier`. The only overrides are at the genuine seams: `get_session` → an `AsyncSession` bound to the testcontainers Postgres (per-test transaction, rolled back at teardown), and `get_token_verifier` → a real HS256 verifier configured with a known test secret (so tests can mint legitimate JWTs via the `mint_token` fixture instead of pasting a Fake-accepted string). + +In-memory Fakes (`InMemoryUserRepository`, `FakeTokenVerifier`, `InMemoryEventBus`, …) **never** appear in `tests/e2e/`. They stay reserved for `tests/unit/` where their Spy facet (`user_repository.users`, `event_bus.published`) gives focused branch coverage without paying for I/O. Assertions in e2e use the API response body or a direct SQL query through `e2e_session_factory` when row-count semantics need to be checked (idempotency proof, "is the row really there?"). + +`StubAsyncSession` is the documented exception: it simulates a DB outage on the readiness probe by raising `OperationalError` on `execute`. That's a failure-mode test, not an I/O-avoidance shortcut. + +### 4. GIVEN/WHEN/THEN strict + +```python +async def test_given__when__then_() -> None: + # GIVEN + + + # WHEN + + + # THEN + +``` + +Tolerance: `# WHEN / THEN` combined is allowed only when the WHEN is `pytest.raises`. Enforced by `scripts/checks/test_naming.py`. Annotation form `# GIVEN: ` is accepted for fixture-driven tests where the GIVEN is fully composed by fixtures. + +#### 4a. One test == one assertion == one reason to fail + +**A single `assert` per test. A test should fail for ONE reason.** + +When a test fails, the test name + the single assert should tell you *exactly* what behaviour broke. Two asserts in the same test means: + +- A failure on the first one hides the second (you never learn if it would have passed). +- The test name has to be vague enough to describe both — and vague names rot fast. +- A regression in one field looks identical to a regression in a different field in the same test. + +Forbidden: + +```python +# ❌ Two asserts, the test name lies (it claims "one thing") +async def test_given_creation_when_executing_then_persists_and_publishes(...): + await use_case.execute(...) + assert len(repo.lists) == 1 # might fail + assert event_bus.published[0] ... # never reached if the above fails +``` + +Required: split into N tests, each with a focused name: + +```python +async def test_given_creation_when_executing_then_persists_one_list(...): + await use_case.execute(...) + assert len(repo.lists) == 1 + +async def test_given_creation_when_inspecting_event_then_name_is_todo_list_created(...): + await use_case.execute(...) + assert event_bus.published[0][0] == "todo_list.created" +``` + +The cost is verbose — but mutation testing and CI failures pay it back +the first time a regression lands. + +**No `@pytest.mark.parametrize`.** If you're tempted to parametrize, +split into N named tests — the test name carries more meaning than a +parametrize id. + +#### 4b. Tolerated multi-statement forms + +- A `pytest.raises(...)` block plus an assert on `excinfo.value.context[...]` is still ONE behavioural assertion (the exception type + its context payload describe the same failure). +- Equality on a whole DTO / dict / list — `assert output == ExpectedOutput(...)` is one assertion, even if it covers many fields. + +#### 4c. Extract repeated GIVEN into fixtures + +If two or more tests share the same GIVEN setup, the setup belongs in a fixture. The GIVEN block of each test then collapses to a `# GIVEN: ` annotation — the form already tolerated by §4. + +Decision tree: + +- **Used in one test file only** → fixture in that test file (a top-level `@pytest.fixture` in the module). +- **Used across multiple test files in the same layer** (e.g. all `tests/integration/infrastructure/...`) → fixture in the nearest `conftest.py` (`tests/integration/conftest.py`, `tests/unit/conftest.py`, etc.). +- **Used across all layers** → fixture in `tests/conftest.py` — the root conftest. This is where `frozen_clock`, `sequential_ids`, `seeded_random`, the in-memory Fakes, and `ensure_user_exists_use_case` already live; new cross-cutting fixtures join them. + +Example — before: + +```python +async def test_given_existing_user_when_X_then_Y() -> None: + # GIVEN + user_repository.users["sub-1"] = User(...) + ... +async def test_given_existing_user_when_Z_then_W() -> None: + # GIVEN + user_repository.users["sub-1"] = User(...) + ... +``` + +After — fixture lives in the same file (or in `tests/unit/conftest.py` if other modules need it): + +```python +@pytest.fixture +def existing_user(user_repository: FakeUserRepository) -> User: + user = User(...) + user_repository.users[user.subject] = user + return user + +async def test_given_existing_user_when_X_then_Y(existing_user: User) -> None: + # GIVEN: an existing user (fixture) + ... +async def test_given_existing_user_when_Z_then_W(existing_user: User) -> None: + # GIVEN: an existing user (fixture) + ... +``` + +Avoid extracting a fixture for a single test — duplicating setup once is cheaper than the indirection. + +### 5. FIRST principles + +- **F**ast — a unit test runs in milliseconds. +- **I**solated — `frozen_clock`, `sequential_ids`, `seeded_random` are the conftest fixtures that buy this. +- **R**epeatable — same input, same output, on any machine. +- **S**elf-validating — a single `assert` line tells you pass/fail. +- **T**imely — write the test before the production code. + +--- + +## Common Fakes shipped with the template + +| Abstraction | Production | Fake | +|---|---|---| +| `Clock` | `SystemClock` | `FrozenClock(at=datetime(...))` | +| `IdGenerator` | `Uuid4IdGenerator` | `SequentialIdGenerator(start=1)` | +| `RandomSource` | `SystemRandomSource` | `SeededRandomSource(seed=0)` | +| `UserRepository` | `SqlAlchemyUserRepository` | `InMemoryUserRepository(seed=[...])` | + +The root conftest exposes them as fixtures (`frozen_clock`, `sequential_ids`, `seeded_random`, `user_repository`, `ensure_user_exists_use_case`, `token_verifier`). + +--- + +## Writing a new Fake + +A Fake is a **working** in-memory implementation of an abstraction. It must be: + +- **Deterministic** — no clock drift, no random without a seed. +- **Cheap** — no network, no disk. +- **Inspectable** — tests assert on `fake.`. +- **Faithful** — enforces the same invariants as the real binding (uniqueness constraints, validation). A Fake that always returns the happy answer is useless. + +### File layout + +- `tests/fakes/.py` for primitives (`clock.py`, `id_generator.py`, `random_source.py`). +- `tests/fakes/repositories/.py` for repositories. + +Mirror the production structure under `tests/fakes/`. + +### Skeleton + +```python +from {{ cookiecutter.package_name }}.application. import + + +class InMemory(): + """In-memory fake for tests. Acts as Spy via its public state.""" + + def __init__(self, seed: = ()) -> None: + self.: = ... + + async def (self, ...) -> ...: + # smallest faithful implementation + ... +``` + +### Acting as a Spy + +```python +fake_email_sender = InMemoryEmailSender() +await use_case.execute(...) +assert fake_email_sender.sent == [Email("alice@example.com")] +``` + +The assertion is on **observable behaviour** (a message was queued for that recipient), not on implementation details. + +### Mistakes to avoid + +- Importing from `infrastructure/` in a Fake — couples Fakes to concretes. +- Using `random.*` without a seed. +- Using `datetime.now()` — Fakes of `Clock` accept the time as a parameter. +- Implementing only the happy path — without the uniqueness / validation invariants the real production code enforces, unit tests pass on impossible scenarios. +- Adding a Fake without registering it as a conftest fixture if more than one test will use it. + +--- + +## Hard rules — the loop + +- **The first failing test is at the OUTER edge** (use case test for a feature). Drop to a domain-level test only for invariants that have no use case context. +- **No production code without a failing test first.** Includes value objects, entities, Protocol methods, Fake methods. The compiler's `ImportError` is your guide. +- **Build minimum.** Don't add a field, a method, or a branch that no test demands. +- **Never disable a failing test to make CI green.** Fix the test or fix the code. +- **Bug fixes follow the same loop.** A bug = a missing outer test. Reproduce it (red), fix it (green), mutate. +- **Mutation step is non-negotiable** when `domain/` or `application/` logic was touched. Line coverage without mutation kill rate is a comforting lie. diff --git a/{{cookiecutter.project_slug}}/.claude/skills/writing-a-helm-change/SKILL.md b/{{cookiecutter.project_slug}}/.claude/skills/writing-a-helm-change/SKILL.md new file mode 100644 index 0000000..27ddf86 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/skills/writing-a-helm-change/SKILL.md @@ -0,0 +1,80 @@ +--- +name: writing-a-helm-change +description: Editing the Helm chart (templates, values, schema, overlays). What ships, how to add a value, how to validate locally, what CI catches. +when_to_use: Editing anything under helm/{{ cookiecutter.project_slug }}/; adding a new env var or secret that the app needs in cluster; reviewing a chart change. +--- + +{% raw %}# Writing a Helm change + +## What ships in the chart + +``` +helm// +├── Chart.yaml +├── values.yaml # default values, documented inline +├── values.schema.json # JSON Schema — Helm rejects unknown keys +├── values-dev.yaml # per-environment overlays +├── values-staging.yaml +├── values-prod.yaml +└── templates/ + ├── _helpers.tpl # name, fullname, labels, selectorLabels, serviceAccountName + ├── configmap.yaml # non-sensitive env (LOG_FORMAT, SERVICE_NAME, OTEL_*, CORS_*) + ├── deployment.yaml # probes, resources, securityContext, terminationGracePeriodSeconds + ├── hpa.yaml # HorizontalPodAutoscaler (opt-in via autoscaling.enabled) + ├── ingress.yaml # opt-in via ingress.enabled + ├── migration-job.yaml # Helm pre-install / pre-upgrade Job running 'alembic upgrade head' + ├── pdb.yaml # PodDisruptionBudget (opt-in) + ├── prometheusrule.yaml # alert rules (opt-in via metrics.alerts.enabled) + ├── relay-deployment.yaml # outbox relay worker (opt-in via relay.enabled) + ├── relay-service.yaml # ClusterIP exposing the relay's /metrics on 9100 + ├── relay-servicemonitor.yaml # Prometheus scrape of the relay + ├── secret.yaml # rendered iff secrets.dbUrl is set; otherwise external secrets fill it + ├── service.yaml + ├── serviceaccount.yaml + ├── servicemonitor.yaml # Prometheus ServiceMonitor (opt-in via metrics.enabled) + └── tests/ + └── test-connection.yaml # 'helm test' smoke check on /health/live +``` + +Every template wraps its Go syntax inside a Jinja `raw` block because the file lives in a Cookiecutter template tree — see the existing files for the pattern. + +## Edit checklist + +1. **Templates are Go templates wrapped in cookiecutter raw blocks.** Inside a template file, use Helm Go template syntax `{{ .Values.X }}`, `{{ include "app.name" . }}`, `{{ .Release.Namespace }}`. Do NOT introduce cookiecutter `{{ cookiecutter.X }}` inside the raw block — those are baked once and cannot vary per environment. +2. **Add the field to BOTH `values.yaml` AND `values.schema.json`.** Helm rejects values not described in the schema. Most "my override was silently ignored" bugs come from a missing schema entry. +3. **Update each environment overlay** if the new field has a meaningful per-environment default (`values-dev.yaml`, `values-staging.yaml`, `values-prod.yaml`). +4. **Probes and shutdown alignment.** Changes to readiness / liveness paths or `terminationGracePeriodSeconds` need to stay coherent with `Dockerfile`'s `--timeout-graceful-shutdown` value (currently 30s); `terminationGracePeriodSeconds` MUST stay strictly greater so K8s never SIGKILLs before uvicorn drains. + +## Validate locally + +```bash +just helm-lint +just helm-render # default: prod overlay +just helm-render dev # any of dev / staging / prod +# Pipe into kubeconform if installed: +just helm-render prod | kubeconform -strict -ignore-missing-schemas - +# Pipe into polaris if installed: +just helm-render prod | polaris audit --audit-path - +``` + +CI runs the same checks in `ci-helm.yml`: `helm lint`, `helm template` on default + prod overlays, `kubeconform -strict`, `polaris audit`. + +## Common patterns + +* **New env var the app reads.** Add it under `values.yaml::config.`, declare it in `values.schema.json`, render it in `templates/configmap.yaml` as a key under `data:`. Then read it from `Settings` in `infrastructure/config/settings.py`. The Deployment already pulls `envFrom: configMapRef: -config`, no template change needed. +* **New secret.** Extend `values.yaml::secrets`, declare in the schema, render in `templates/secret.yaml` under `stringData:`. The Deployment is already wired to `envFrom: secretRef:` (rendered conditionally on the secret existing). +* **Enable metrics scraping.** Flip `metrics.enabled: true` in the overlay — the `ServiceMonitor` template renders (for the API on port `http` and the relay on port `metrics`), a Prometheus Operator picks them up. The `/metrics` endpoint is always exposed by the app and the relay regardless; the gate is on cluster-side scraping. +* **Enable alert rules.** Set `metrics.alerts.enabled: true`. Renders a `PrometheusRule` with 4 outbox alerts (backlog, lag, poisoned, handler error rate), 3 API alerts (5xx rate, p99 latency, no replicas), and 1 relay availability alert (no replicas). Tune thresholds under `metrics.alerts.thresholds.*` per env. Use `metrics.alerts.labels` to inject routing labels (e.g. `team: backend`) for Alertmanager. +* **Add a new alert.** Append a `rule` entry under the right group in `templates/prometheusrule.yaml`. Each rule needs `alert`, `expr`, `for`, `labels.severity` (`warning` or `critical`), `annotations.summary` and `annotations.description`. If the alert references a threshold, declare a default in `values.yaml::metrics.alerts.thresholds` and add it to `values.schema.json`. Cross-link the runbook procedure in the description (most outbox runbooks live in the `building-a-feature` § "Transactions and events" section). +* **New Helm hook Job (besides migrations).** Copy the structure from `templates/migration-job.yaml`. Set `helm.sh/hook-weight` to control ordering relative to the migrations job (which is at weight `-5`, so it runs first). +* **Tune resources / replicas / autoscaling per env.** Edit the overlay (`values-.yaml`), not the base `values.yaml`. The base carries safe defaults; overlays carry the env-specific tuning. + +## Things to avoid + +* **Hard-coded namespaces.** Use `{{ .Release.Namespace }}` if the template needs to know. +* **`latest` image tags.** Production deployments must be deterministic — `image.tag` is set per-release via CI or values overlay, defaults to `.Chart.AppVersion`. +* **Privileged or root pods.** `podSecurityContext.runAsNonRoot: true` is the shipped default; do not remove it. Same for the dropped capabilities in `securityContext.capabilities.drop`. +* **Removing `migrations.enabled`.** If the project moves away from Alembic, replace the schema management strategy in the same PR — do not just delete the gate. +* **Shrinking `terminationGracePeriodSeconds` below 30.** The Dockerfile gives uvicorn 30 seconds to drain; anything less will SIGKILL in-flight requests. +* **Adding a value without a schema entry.** Helm will accept the override silently in some versions and reject it in others — always update `values.schema.json` in the same diff. +{% endraw %} diff --git a/{{cookiecutter.project_slug}}/.claude/skills/writing-domain-code/SKILL.md b/{{cookiecutter.project_slug}}/.claude/skills/writing-domain-code/SKILL.md new file mode 100644 index 0000000..6cbbae6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.claude/skills/writing-domain-code/SKILL.md @@ -0,0 +1,229 @@ +--- +name: writing-domain-code +description: Hard rules + modelling patterns for code under src//domain/ — purity, entities, value objects, and domain exceptions (with their HTTP mapping). The domain is pure, deterministic, third-party-free. +when_to_use: Editing any file under src//domain/. Designing a new entity / value object / business rule. Adding a new DomainError subclass. Reviewing a domain change. +--- + +# Writing domain code + +## Hard rules (mechanically enforced) + +1. **No third-party imports.** Stdlib only. `pydantic`, `sqlalchemy`, `httpx`, `structlog`, `fastapi` — all forbidden here. Enforced by `scripts/checks/no_third_party_in_domain.py`. + +2. **No non-deterministic side effects.** `datetime.now()`, `datetime.utcnow()`, `uuid.uuid4()`, `random.*`, `time.time()` are forbidden — they break the **R**epeatable property of FIRST tests. Use the `Clock`, `IdGenerator`, `RandomSource` abstractions from `application/`. The domain may freely **type** with `datetime`, `UUID`, etc. — only the side-effecting calls are banned. + +3. **No log calls.** The domain is silent. Logging is an infrastructure concern; the domain raises typed exceptions instead. + +4. **TZ-aware datetimes only.** Always pass `datetime` with `tz=UTC` (or another explicit zone). A naive `datetime` is treated as a bug. + +## What `domain/` may import + +Stdlib only. The typical surface is: + +- `datetime`, `time`, `zoneinfo` for time types +- `decimal.Decimal` for money / precise quantities +- `uuid.UUID` for identifier types +- `enum`, `typing`, `dataclasses`, `abc`, `re` +- `pathlib.Path` if you really need it +- Other modules under `{{ cookiecutter.package_name }}.domain.*` + +Plus the base classes shipped with the template: + +```python +from {{ cookiecutter.package_name }}.domain.entities.base import Entity +from {{ cookiecutter.package_name }}.domain.value_objects.base import ValueObject +from {{ cookiecutter.package_name }}.domain.exceptions.base import DomainError +``` + +--- + +## Modelling + +### Value objects — `domain/value_objects/` + +Frozen + slotted dataclass inheriting from `ValueObject` (marker; preserves slots in subclasses). Validation in `__post_init__`. Equality is value-based (inherited from dataclass). + +A typed identifier wrapping a primitive — the canonical small VO: + +```python +from dataclasses import dataclass +from uuid import UUID + +from {{ cookiecutter.package_name }}.domain.value_objects.base import ValueObject + + +@dataclass(frozen=True, slots=True) +class UserId(ValueObject): + """Strongly-typed wrapper around a user identifier UUID.""" + + value: UUID + + def __str__(self) -> str: + return str(self.value) +``` + +A VO that enforces a business invariant on construction: + +```python +from decimal import Decimal + + +# Hypothetical example — Money / NegativeMoneyError / InvalidCurrencyError +# are not shipped; use the same pattern for your own VOs. +@dataclass(frozen=True, slots=True) +class Money(ValueObject): + amount: Decimal + currency: str # ISO-4217 + + def __post_init__(self) -> None: + if self.amount < 0: + raise NegativeMoneyError(amount=str(self.amount)) + if len(self.currency) != 3 or not self.currency.isupper(): + raise InvalidCurrencyError(currency=self.currency) +``` + +`frozen=True` makes the instance immutable (hash works, instance is safe to share). `slots=True` saves memory and prevents typos creating new attributes. + +### Entities — `domain/entities/` + +Inherit from `Entity[TId]`. `__eq__` and `__hash__` are derived from `(type, self.id)` — you only set attributes in `__init__`. Mutations go through **named methods** (`order.cancel()`, `account.credit(amount)`), never direct attribute assignment from outside the class. + +```python +# Hypothetical example — copy the pattern. +from {{ cookiecutter.package_name }}.domain.entities.base import Entity + + +class Order(Entity[OrderId]): + """A customer order.""" + + def __init__(self, *, id: OrderId, items: list[OrderLine], status: OrderStatus) -> None: + self.id = id + self._items = list(items) + self._status = status + + def cancel(self) -> None: + if self._status is OrderStatus.SHIPPED: + raise OrderCannotBeCancelledError(order_id=str(self.id)) + self._status = OrderStatus.CANCELLED +``` + +The User entity at `domain/entities/user.py` is the canonical reference. + +### Domain services — when an operation spans multiple entities + +If an operation does not naturally belong to a single entity (e.g. transferring funds between two accounts), put it as a free function or a small stateless class in `domain/services/.py`. Domain services follow the same purity rules. + +Most projects do not need this. Reach for it only when "this method on `Account.withdraw_to(other)` would mean Account knows about its peers" feels wrong. + +--- + +## Domain exceptions + +Every business rule violation surfaces as a typed `DomainError` subclass. The class name + `code` + HTTP mapping form a stable contract clients depend on. + +### 1. Pick the bounded context + +Group exceptions by what they describe, not by the class that raises them. Put the new class under `domain/exceptions/.py`: + +- `user.py` for everything about user lifecycle, identity, authentication +- `billing.py` for prices, balance, payments +- `inventory.py` for stock, fulfilment + +Create the module if it doesn't exist. The User feature ships three working examples in `domain/exceptions/user.py` (`UserAlreadyExistsError`, `UserNotFoundError`) and `domain/value_objects/email.py` (`InvalidEmailError`) — copy the pattern. + +### 2. Write the class + +Subclass `DomainError`. Set the **class-level** `code` and `default_message` (both are `ClassVar` on the base): + +```python +from {{ cookiecutter.package_name }}.domain.exceptions.base import DomainError + + +class InsufficientBalanceError(DomainError): + """Raised when an account has not enough funds for a withdrawal.""" + + code = "INSUFFICIENT_BALANCE" + default_message = "The account does not have sufficient balance." +``` + +- `code` is **SCREAMING_SNAKE** and **stable** — clients deserialize on it. +- `default_message` is one English sentence safe to show to an end user. +- The class name follows `Error` (`UserAlreadyExistsError`, `OrderCannotBeCancelledError`). +- The docstring is mandatory (ruff `D101`). + +### Optional: rich context for logs + +`DomainError.__init__` accepts arbitrary kwargs and stores them under `.context`: + +```python +raise UserAlreadyExistsError(email=str(email), source="signup-form") +``` + +The `_instrumented` wrapper in `infrastructure/container.py` extracts `code` and `context` and emits a structured `WARNING` log — no extra logging code in your use case. + +### 3. Register the HTTP mapping + +`presentation/api/error_handlers.py`: + +```python +ERROR_HTTP_MAPPING: dict[type[DomainError], int] = { + ... + InsufficientBalanceError: 402, # Payment Required + ... +} +``` + +The mapping table is the **single source of truth**. The unit test `tests/unit/presentation/test_error_mapping.py` discovers `DomainError.__subclasses__()` automatically and fails the build when a subclass is unmapped. The Claude `audit-edit.sh` hook also flags it the moment you save the new class file. + +If you are tempted to give the same exception type a different HTTP status in different endpoints, that means the type itself is not specific enough — extract a more precise `…Error`. Per-endpoint overrides are not supported on purpose. + +### 4. Raise it from the use case (TDD red first) + +Following `tdd-workflow`: + +```python +# tests/unit/use_cases/test__.py +async def test_given__when__then_raises_( + : , +) -> None: + # GIVEN: setup that triggers the rule + ... + + # WHEN / THEN + with pytest.raises(InsufficientBalanceError) as excinfo: + await .execute(...) + assert excinfo.value.code == "INSUFFICIENT_BALANCE" +``` + +Then the production code that makes it pass: + +```python +# application/use_cases/.py +if account.balance < amount: + raise InsufficientBalanceError( + account_id=str(account.id), required=str(amount), available=str(account.balance) + ) +``` + +`just test` until green, then `just mutate` to confirm the test would also catch a "no raise" mutation. + +### 5. E2E impact (only if the error reaches the API) + +If the use case is exposed through an HTTP endpoint **and** this error is the *primary* error path for that endpoint, replace the existing "1 critical error" e2e test with one targeting the new exception. Otherwise leave the e2e suite alone — branch coverage lives at the unit level. + +### HTTP code cheat sheet + +| Domain meaning | HTTP | +|---|---| +| Generic bad request, malformed body that Pydantic did not catch | 400 | +| Missing resource (lookup returned `None`) | 404 | +| Conflict (duplicate, version mismatch) | 409 | +| Resource removed permanently | 410 | +| Validation failure on a well-shaped input (business rule) | 422 | +| Authentication missing | 401 | +| Authorization insufficient | 403 | +| Payment required | 402 | +| Resource locked (concurrent edit) | 423 | +| Rate limited | 429 | +| Downstream service unavailable | 503 | +| Internal invariant broken (should never happen) | 500 — the fallback handler covers it | diff --git a/{{cookiecutter.project_slug}}/.devcontainer/Dockerfile b/{{cookiecutter.project_slug}}/.devcontainer/Dockerfile new file mode 100644 index 0000000..e8f583c --- /dev/null +++ b/{{cookiecutter.project_slug}}/.devcontainer/Dockerfile @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/devcontainers/base:bookworm + +RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh + +RUN curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh \ + | bash -s -- --to /usr/local/bin + +USER vscode + +RUN uv python install {{ cookiecutter.python_version }} diff --git a/{{cookiecutter.project_slug}}/.devcontainer/devcontainer.json b/{{cookiecutter.project_slug}}/.devcontainer/devcontainer.json new file mode 100644 index 0000000..73ea814 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.devcontainer/devcontainer.json @@ -0,0 +1,77 @@ +{ + "name": "{{ cookiecutter.project_name }}", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + "remoteUser": "vscode", + "updateRemoteUserUID": true, + + "containerEnv": { + "TZ": "Europe/Paris" + }, + + "mounts": [ + "source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,readonly", + "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,readonly" + ], + + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "username": "vscode", + "uid": "automatic", + "gid": "automatic" + }, + // Installs docker CLI inside the container and reconciles the vscode + // user with the host docker.sock GID at postCreate, so testcontainers + // can spawn ephemeral Postgres for integration tests. When the stack + // is brought up with plain 'docker compose up' (outside the devcontainer + // CLI flow), this Feature does NOT run and the pg_url fixture skips + // integration tests cleanly — that path is the documented fallback. + // + // moby=false: install upstream docker-ce-cli rather than Moby. The + // Microsoft devcontainers/python:3.13 base ships a Debian release for + // which the Moby package is not published; the Feature errors out + // with a clear hint pointing at this exact option. + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "moby": false + } + }, + // just is baked into the Dockerfile so it is present even when the stack + // is brought up with plain `docker compose up` (outside the devcontainer + // CLI flow that applies Features). + + "postCreateCommand": "just install && just hooks", + "postStartCommand": "just migrate || true", + + "forwardPorts": [8000, 5432, 16686{% if cookiecutter.swagger_auth_scheme == "oauth2_auth_code" %}, 8080{% endif %}], + "portsAttributes": { + "8000": { "label": "FastAPI app", "onAutoForward": "openPreview" }, + "5432": { "label": "Postgres", "onAutoForward": "silent" }, + "16686": { "label": "Jaeger UI", "onAutoForward": "notify" }{% if cookiecutter.swagger_auth_scheme == "oauth2_auth_code" %}, + "8080": { "label": "Keycloak (local IdP)", "onAutoForward": "silent" }{% endif %} + }, + + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "ms-python.python", + "ms-python.vscode-pylance", + "tamasfe.even-better-toml", + "redhat.vscode-yaml" + ], + "settings": { + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll.ruff": "explicit" + } + } + } + } + } +} diff --git a/{{cookiecutter.project_slug}}/.devcontainer/docker-compose.yml b/{{cookiecutter.project_slug}}/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..94a904f --- /dev/null +++ b/{{cookiecutter.project_slug}}/.devcontainer/docker-compose.yml @@ -0,0 +1,148 @@ +# Local development stack: app container + Postgres + Jaeger + OTel collector. +# Volumes are named so DB state survives container rebuilds. +# Networking: all services share the default network of this compose file. + +services: + app: + build: + context: . + dockerfile: Dockerfile + # post-up.sh fixes docker.sock GID access for vscode when the stack is + # brought up via plain `docker compose up` (the devcontainer CLI would + # otherwise run the docker-outside-of-docker:1 Feature). Idempotent. + command: ["bash", "-c", "sudo /workspaces/{{ cookiecutter.project_slug }}/.devcontainer/post-up.sh || true; exec sleep infinity"] + volumes: + - ../..:/workspaces:cached + # Mount the host docker.sock so testcontainers can spawn ephemeral + # containers from inside the devcontainer (used by integration tests). + # SECURITY: this gives the devcontainer root-equivalent access to the + # host. Standard trade-off for "Docker outside of Docker" dev setups. + # If you don't run integration tests from inside the devcontainer, + # comment this line out — the test fixture pg_url will skip cleanly. + - /var/run/docker.sock:/var/run/docker.sock + # Make host.docker.internal resolve from inside the devcontainer (Linux + # needs this; macOS/Windows resolve it automatically). testcontainers + # uses it to reach ephemeral PG containers running on the host. + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + DB_URL: postgresql+asyncpg://app:app@postgres:5432/{{ cookiecutter.package_name }} + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 + LOG_FORMAT: console + LOG_LEVEL: DEBUG + # testcontainers-python: rewrite the spawned container's host from + # localhost to host.docker.internal so our tests reach it. + TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal + depends_on: + postgres: + condition: service_healthy +{%- if cookiecutter.swagger_auth_scheme == "oauth2_auth_code" %} + keycloak: + condition: service_healthy +{%- endif %} +{%- if cookiecutter.include_otel == "yes" %} + jaeger: + condition: service_started + otel-collector: + condition: service_started +{%- endif %} + networks: + - soma-dev + + # Outbox relay sidecar — auto-starts so domain events emitted by the API + # flow through to handlers without manual intervention. Shares the same + # workspace mount as `app` so it picks up code changes; restarts in a + # loop while migrations haven't run yet (table missing → restart). + relay: + build: + context: . + dockerfile: Dockerfile + working_dir: /workspaces/{{ cookiecutter.project_slug }} + command: ["sh", "-c", "uv sync --group dev && uv run python -m {{ cookiecutter.package_name }}.infrastructure.jobs.outbox_relay"] + volumes: + - ../..:/workspaces:cached + environment: + DB_URL: postgresql+asyncpg://app:app@postgres:5432/{{ cookiecutter.package_name }} + LOG_FORMAT: console + LOG_LEVEL: DEBUG +{%- if cookiecutter.include_otel == "yes" %} + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 +{%- endif %} + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + networks: + - soma-dev + + postgres: + image: postgres:17 + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: {{ cookiecutter.package_name }} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d {{ cookiecutter.package_name }}"] + interval: 5s + timeout: 3s + retries: 5 + networks: + - soma-dev + +{%- if cookiecutter.swagger_auth_scheme == "oauth2_auth_code" %} + # Local IdP for the OAuth2 Authorization Code flow exposed by Swagger UI. + # Realm 'dev' is auto-imported on first start; seeded users: alice/alice + # (regular), admin/admin (admin role). Tokens carry aud=api. + # Browser → http://localhost:8080 | App container → http://keycloak:8080 + keycloak: + image: quay.io/keycloak/keycloak:26.0 + command: + - start-dev + - --import-realm + - --http-port=8080 + - --hostname-strict=false + - --hostname=http://localhost:8080 + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + KC_LOG_LEVEL: WARN + volumes: + - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 60s + networks: + - soma-dev +{%- endif %} + +{%- if cookiecutter.include_otel == "yes" %} + jaeger: + image: jaegertracing/all-in-one:1.75.0 + environment: + COLLECTOR_OTLP_ENABLED: "true" + networks: + - soma-dev + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.140.0 + command: ["--config=/etc/otel-collector.yaml"] + volumes: + - ./otel-collector.yaml:/etc/otel-collector.yaml:ro + depends_on: + - jaeger + networks: + - soma-dev +{%- endif %} + +volumes: + postgres-data: + +networks: + soma-dev: + driver: bridge diff --git a/{{cookiecutter.project_slug}}/.devcontainer/keycloak/realm-export.json b/{{cookiecutter.project_slug}}/.devcontainer/keycloak/realm-export.json new file mode 100644 index 0000000..4e39519 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.devcontainer/keycloak/realm-export.json @@ -0,0 +1,105 @@ +{ + "realm": "dev", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "accessTokenLifespan": 1800, + "ssoSessionIdleTimeout": 3600, + "ssoSessionMaxLifespan": 36000, + "roles": { + "realm": [ + { "name": "admin", "description": "Tenant admin role used by the seeded admin user." } + ] + }, + "clientScopes": [ + { + "name": "api-audience", + "description": "Adds aud=api to access tokens so the FastAPI verifier accepts them.", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "api-audience-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "api", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + } + ], + "clients": [ + { + "clientId": "swagger-ui", + "name": "Swagger UI (local dev)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "implicitFlowEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:8000/docs/oauth2-redirect", + "http://localhost:8000/*" + ], + "webOrigins": [ + "http://localhost:8000", + "+" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "+" + }, + "defaultClientScopes": [ + "web-origins", + "profile", + "roles", + "email", + "api-audience" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "users": [ + { + "username": "alice", + "enabled": true, + "email": "alice@dev.local", + "emailVerified": true, + "firstName": "Alice", + "lastName": "Dev", + "credentials": [ + { "type": "password", "value": "alice", "temporary": false } + ] + }, + { + "username": "admin", + "enabled": true, + "email": "admin@dev.local", + "emailVerified": true, + "firstName": "Admin", + "lastName": "Dev", + "credentials": [ + { "type": "password", "value": "admin", "temporary": false } + ], + "realmRoles": ["admin"] + } + ] +} diff --git a/{{cookiecutter.project_slug}}/.devcontainer/otel-collector.yaml b/{{cookiecutter.project_slug}}/.devcontainer/otel-collector.yaml new file mode 100644 index 0000000..775b6f8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.devcontainer/otel-collector.yaml @@ -0,0 +1,35 @@ +{% if cookiecutter.include_otel == "yes" -%} +# Minimal OTel collector for local development. +# Receives OTLP gRPC + HTTP, batches, exports to Jaeger. + +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + timeout: 1s + +exporters: + otlp/jaeger: + endpoint: jaeger:4317 + tls: + insecure: true + debug: + verbosity: basic + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp/jaeger, debug] +{%- else -%} +# OTel is not included in this generated project. This file is kept as a +# placeholder so the docker-compose.yml volume mount does not fail. Re-enable +# OTel by regenerating with include_otel=yes. +{%- endif %} diff --git a/{{cookiecutter.project_slug}}/.devcontainer/post-up.sh b/{{cookiecutter.project_slug}}/.devcontainer/post-up.sh new file mode 100755 index 0000000..78f6805 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.devcontainer/post-up.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Configure docker.sock access for the vscode user. +# Runs at container startup. Idempotent. Safe to re-run. +# +# This is needed when the devcontainer stack is brought up via plain +# `docker compose up` (i.e. NOT via the devcontainer CLI). The +# docker-outside-of-docker:1 Feature in devcontainer.json normally +# detects the host's docker.sock GID and adds the vscode user to a +# matching group, but the Feature only runs through the CLI flow. +# Plain `docker compose up` bypasses it, so this script is the fallback +# that the compose `command:` invokes via sudo before sleeping. +# +# Without this, integration tests that use testcontainers will skip +# silently because the vscode user cannot reach /var/run/docker.sock. +set -euo pipefail + +if [ ! -S /var/run/docker.sock ]; then + echo "[post-up] docker.sock not mounted; nothing to configure." + exit 0 +fi + +SOCK_GID="$(stat -c '%g' /var/run/docker.sock)" + +# If a group with that GID doesn't exist yet, create one named docker-host. +if ! getent group "$SOCK_GID" > /dev/null 2>&1; then + groupadd -g "$SOCK_GID" docker-host + echo "[post-up] Created group docker-host (GID $SOCK_GID)." +fi + +GROUP_NAME="$(getent group "$SOCK_GID" | cut -d: -f1)" + +# Idempotent: usermod -aG is a no-op if vscode is already in the group. +if ! id -nG vscode | tr ' ' '\n' | grep -qx "$GROUP_NAME"; then + usermod -aG "$GROUP_NAME" vscode + echo "[post-up] Added vscode to $GROUP_NAME (GID $SOCK_GID)." +else + echo "[post-up] vscode already in $GROUP_NAME; nothing to change." +fi + +echo "[post-up] Docker socket access ready for vscode." diff --git a/{{cookiecutter.project_slug}}/.dockerignore b/{{cookiecutter.project_slug}}/.dockerignore new file mode 100644 index 0000000..afe85a9 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.dockerignore @@ -0,0 +1,27 @@ +__pycache__ +*.pyc +*.pyo +.venv +venv +.git +.gitignore +.pytest_cache +.mypy_cache +.ty_cache +.ruff_cache +.coverage +htmlcov +.mutmut-cache +mutants +docs +helm +tests +examples +.devcontainer +.github +.vscode +.idea +.env +.env.* +!.env.example +*.md diff --git a/{{cookiecutter.project_slug}}/.env.example b/{{cookiecutter.project_slug}}/.env.example new file mode 100644 index 0000000..fa7cd0d --- /dev/null +++ b/{{cookiecutter.project_slug}}/.env.example @@ -0,0 +1,77 @@ +# Copy to .env and fill in real values. .env is gitignored. +# Pydantic Settings validates these at boot — missing required variables crash early. + +# ── App ───────────────────────────────────────────────────────────── +APP_NAME={{ cookiecutter.project_name }} +LOG_FORMAT=console # console | json +LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR +SERVICE_NAME={{ cookiecutter.package_name }} + +# ── Database ──────────────────────────────────────────────────────── +{%- if cookiecutter.database == "postgres" %} +DB_URL=postgresql+asyncpg://app:app@localhost:5432/{{ cookiecutter.package_name }} +{%- else %} +DB_URL=sqlite+aiosqlite:///./local.db +{%- endif %} +DB_POOL_SIZE=10 +DB_POOL_MAX_OVERFLOW=5 +DB_POOL_TIMEOUT=30 + +# ── Observability ─────────────────────────────────────────────────── +{%- if cookiecutter.include_otel == "yes" %} +# Leave OTEL_EXPORTER_OTLP_ENDPOINT empty to log spans to console (zero-config dev). +OTEL_EXPORTER_OTLP_ENDPOINT= +OTEL_TRACES_SAMPLER_ARG=1.0 +{%- endif %} + +# ── Auth ──────────────────────────────────────────────────────────── +{%- if cookiecutter.swagger_auth_scheme == "oauth2_auth_code" %} +# Defaults wired to the local Keycloak shipped in .devcontainer/docker-compose.yml. +# Login from Swagger with alice/alice (regular) or admin/admin (admin role). +# To target a real IdP, see docs/swagger-oauth2.md. +# +# AUTH_JWT_ISSUER must match what the browser-issued token carries (frontend host). +# AUTH_JWT_JWKS_URL is what the app container uses to fetch the JWKS — it's the +# in-network address, deliberately different from the issuer. +AUTH_JWT_ALGORITHM=RS256 +AUTH_JWT_SECRET= +AUTH_JWT_JWKS_URL=http://keycloak:8080/realms/dev/protocol/openid-connect/certs +AUTH_JWT_AUDIENCE=api +AUTH_JWT_ISSUER=http://localhost:8080/realms/dev +AUTH_JWT_ROLES_CLAIM=realm_access.roles +{%- else %} +# Default HS256 is fine for dev / internal services. Switch to RS256 + +# AUTH_JWT_JWKS_URL to plug into a real IdP — see the ``adding-auth`` skill. +AUTH_JWT_ALGORITHM=HS256 +AUTH_JWT_SECRET=dev-jwt-secret-change-me-in-real-environments +AUTH_JWT_JWKS_URL= +AUTH_JWT_AUDIENCE= +AUTH_JWT_ISSUER= +AUTH_JWT_ROLES_CLAIM=roles +{%- endif %} + +# ── Swagger UI OAuth2 ─────────────────────────────────────────────── +{%- if cookiecutter.swagger_auth_scheme == "oauth2_auth_code" %} +# Defaults point at the local Keycloak shipped with the devcontainer: +# Swagger UI -> "Authorize" -> redirect to http://localhost:8080 -> login +# as alice/alice or admin/admin -> token issued with aud=api. +# To target a real IdP (Azure / Auth0 / managed Keycloak), replace these +# four URLs/IDs — see docs/swagger-oauth2.md. +SWAGGER_OAUTH2_AUTHORIZATION_URL=http://localhost:8080/realms/dev/protocol/openid-connect/auth +SWAGGER_OAUTH2_TOKEN_URL=http://localhost:8080/realms/dev/protocol/openid-connect/token +SWAGGER_OAUTH2_CLIENT_ID=swagger-ui +SWAGGER_OAUTH2_SCOPES=openid,email,profile +SWAGGER_OAUTH2_PKCE_ENABLED=true +{%- else %} +# Leave the OAuth2 URLs empty to use the simpler "paste a JWT" Swagger UI +# experience. Set them later (and a Swagger-UI app on your IdP) to enable +# a one-click SSO login — see docs/swagger-oauth2.md. +SWAGGER_OAUTH2_AUTHORIZATION_URL= +SWAGGER_OAUTH2_TOKEN_URL= +SWAGGER_OAUTH2_CLIENT_ID= +SWAGGER_OAUTH2_SCOPES=openid,email,profile +SWAGGER_OAUTH2_PKCE_ENABLED=true +{%- endif %} + +# ── Security ──────────────────────────────────────────────────────── +CORS_ALLOWED_ORIGINS=http://localhost:3000 diff --git a/{{cookiecutter.project_slug}}/.github/CODEOWNERS b/{{cookiecutter.project_slug}}/.github/CODEOWNERS new file mode 100644 index 0000000..23b4c1b --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/CODEOWNERS @@ -0,0 +1,30 @@ +# GitHub auto-routes review requests to the matching owners on every PR. +# Patterns are processed top-to-bottom; the LAST matching pattern wins. +# Replace @your-team with the actual GitHub team or username before merging +# the first PR — until you do, the default rule below makes every PR depend +# on a non-existent reviewer and CodeOwners will warn. +# +# Reference: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-security/customizing-your-repository/about-code-owners + +# Default owner for any path not matched below. +* @your-team + +# Architecture-critical paths get the architecture group. +/src/*/domain/ @your-team @your-architects +/src/*/application/ @your-team @your-architects +/docs/adr/ @your-team @your-architects + +# Schema and migrations require a DB-savvy reviewer. +/alembic/ @your-team @your-dba +/src/*/infrastructure/persistence/ @your-team @your-dba + +# Deployment surface requires the platform team. +/helm/ @your-team @your-platform +/Dockerfile @your-team @your-platform +/.devcontainer/ @your-team @your-platform + +# CI changes require the platform team to avoid pipeline drift. +/.github/workflows/ @your-team @your-platform + +# Security-sensitive paths require security review. +/SECURITY.md @your-team @your-security diff --git a/{{cookiecutter.project_slug}}/.github/PULL_REQUEST_TEMPLATE.md b/{{cookiecutter.project_slug}}/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..fc6c3d7 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,39 @@ +## Summary + +[What does this PR do and why? Reference the issue/use case it addresses.] + +## Type of change + +- [ ] `feat` — New feature (use case, endpoint, value object…) +- [ ] `fix` — Bug fix +- [ ] `refactor` — Internal refactor without behaviour change +- [ ] `docs` — Documentation only +- [ ] `test` — Tests only +- [ ] `ci` — CI/CD pipeline +- [ ] `chore` — Maintenance / dependency upgrade + +## Test plan + +- [ ] Unit tests added (Fakes only, GIVEN/WHEN/THEN) +- [ ] Integration tests added if a new infrastructure piece was wired +- [ ] E2E tests added if a new endpoint was added (1 happy + 1 critical error) +- [ ] `uv run mutmut run` passes the 90% threshold on `domain` + `application` +- [ ] `uv run pre-commit run --all-files` passes locally + +## Architecture self-check + +- [ ] No third-party imports in `src//domain/` +- [ ] No `datetime.now()` outside `infrastructure/`/`presentation/` boundaries +- [ ] No `float` in `domain/` (use `Decimal` for monetary or precise quantities) +- [ ] New `DomainError` subclasses have an entry in `presentation/api/error_handlers.py::ERROR_HTTP_MAPPING` +- [ ] No log calls inside `domain/` or `application/` +- [ ] Conventional commit messages + +## AI assistance + +- [ ] Part of this code was AI-assisted + - If yes, list: [tool / parts / human review notes] + +## Linked issues + +Closes # diff --git a/{{cookiecutter.project_slug}}/.github/dependabot.yml b/{{cookiecutter.project_slug}}/.github/dependabot.yml new file mode 100644 index 0000000..b7d8ffe --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + labels: ["dependencies", "github-actions"] + commit-message: { prefix: "chore", include: "scope" } + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + labels: ["dependencies", "python"] + commit-message: { prefix: "chore", include: "scope" } + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + labels: ["dependencies", "docker"] + commit-message: { prefix: "chore", include: "scope" } diff --git a/{{cookiecutter.project_slug}}/.github/workflows/ci-helm.yml b/{{cookiecutter.project_slug}}/.github/workflows/ci-helm.yml new file mode 100644 index 0000000..50567ff --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/workflows/ci-helm.yml @@ -0,0 +1,36 @@ +name: CI Helm + +on: + pull_request: + branches: [main] + paths: + - "helm/**" + - ".github/workflows/ci-helm.yml" + +jobs: + helm: + name: helm lint + kubeconform + polaris + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-helm@v4 + - name: helm lint + run: helm lint helm/{{ cookiecutter.project_slug }} + - name: helm template (default values) + run: helm template t helm/{{ cookiecutter.project_slug }} --set secrets.dbUrl=postgresql+asyncpg://test/test > /tmp/manifests-default.yaml + - name: helm template (prod overlay) + run: helm template t helm/{{ cookiecutter.project_slug }} -f helm/{{ cookiecutter.project_slug }}/values-prod.yaml --set secrets.dbUrl=postgresql+asyncpg://test/test > /tmp/manifests-prod.yaml + - name: Install kubeconform + run: | + curl -L https://github.com/yannh/kubeconform/releases/latest/download/kubeconform-linux-amd64.tar.gz | tar xz + sudo mv kubeconform /usr/local/bin/ + - name: kubeconform default + run: kubeconform -strict -summary -ignore-missing-schemas /tmp/manifests-default.yaml + - name: kubeconform prod + run: kubeconform -strict -summary -ignore-missing-schemas /tmp/manifests-prod.yaml + - name: Install polaris + run: | + curl -L https://github.com/FairwindsOps/polaris/releases/latest/download/polaris_linux_amd64.tar.gz | tar xz + sudo mv polaris /usr/local/bin/ + - name: polaris audit (best practices) + run: polaris audit --audit-path /tmp/manifests-prod.yaml --format=pretty || true diff --git a/{{cookiecutter.project_slug}}/.github/workflows/ci-image-scan.yml b/{{cookiecutter.project_slug}}/.github/workflows/ci-image-scan.yml new file mode 100644 index 0000000..f36bbde --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/workflows/ci-image-scan.yml @@ -0,0 +1,59 @@ +name: CI Image Scan + +# Builds the production image (no push) and runs supply-chain checks: +# - Trivy: vulnerability scan, fail on HIGH/CRITICAL CVEs that have a fix +# - Syft: CycloneDX SBOM produced as a PR artefact +# Production-grade hygiene baseline; the release.yml workflow signs and +# pushes once a tag is cut. + +on: + pull_request: + branches: [main] + paths: + - "Dockerfile" + - "src/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/ci-image-scan.yml" + +jobs: + scan: + name: Trivy + Syft on a freshly built image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Build (no push, load locally) + uses: docker/build-push-action@v6 + with: + context: . + target: runtime + load: true + tags: app:pr + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Trivy image scan + uses: aquasecurity/trivy-action@master + with: + image-ref: app:pr + format: table + exit-code: "1" + severity: HIGH,CRITICAL + ignore-unfixed: true + vuln-type: os,library + + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@v0 + with: + image: app:pr + format: cyclonedx-json + output-file: sbom.cdx.json + + - name: Upload SBOM artefact + uses: actions/upload-artifact@v4 + with: + name: sbom-cdx-json + path: sbom.cdx.json + retention-days: 30 diff --git a/{{cookiecutter.project_slug}}/.github/workflows/ci-migration-drift.yml b/{{cookiecutter.project_slug}}/.github/workflows/ci-migration-drift.yml new file mode 100644 index 0000000..787a19a --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/workflows/ci-migration-drift.yml @@ -0,0 +1,86 @@ +name: CI Migration Drift + +# Detects when an ORM model has been changed without a matching Alembic +# revision committed. Runs `alembic revision --autogenerate` against a +# disposable Postgres service and fails the PR if the produced file would +# contain non-trivial upgrade/downgrade ops. + +on: + pull_request: + branches: [main] + paths: + - "src/**/persistence/**" + - "src/**/infrastructure/persistence/**" + - "alembic/**" + - "pyproject.toml" + - ".github/workflows/ci-migration-drift.yml" + +jobs: + check-drift: + name: alembic autogenerate diff + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: drift_check + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U app -d drift_check" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + env: + DB_URL: postgresql+asyncpg://app:app@localhost:5432/drift_check + + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: { enable-caching: true } + + - run: uv sync --frozen --group dev + + - name: Apply existing migrations to baseline + run: uv run alembic upgrade head + + - name: Generate a revision against the current models + run: | + uv run alembic revision --autogenerate -m "drift_check" > /tmp/alembic.log 2>&1 || { + cat /tmp/alembic.log + exit 1 + } + + - name: Inspect the generated revision + id: inspect + shell: bash + run: | + set -euo pipefail + GENERATED=$(ls -t alembic/versions/*drift_check*.py 2>/dev/null | head -n1 || true) + if [ -z "$GENERATED" ]; then + echo "no_drift=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Inspecting $GENERATED" + # A drift-free autogenerate produces upgrade()/downgrade() bodies that + # are just 'pass' (or end with 'pass') and contain no op.* calls. + if grep -E '^[[:space:]]*op\.' "$GENERATED" >/dev/null; then + echo "" + echo "Migration drift detected. Models changed but no Alembic revision was committed." + echo "Generated revision excerpt:" + echo "----------------------------------------" + sed -n '/^def upgrade/,/^def downgrade/p' "$GENERATED" | head -n 60 + echo "----------------------------------------" + echo "" + echo "Run locally:" + echo " uv run alembic revision --autogenerate -m \"\"" + echo "Read the produced file, refine, and commit." + exit 1 + fi + echo "no_drift=true" >> "$GITHUB_OUTPUT" + rm -f "$GENERATED" + echo "No drift — generated revision is empty." diff --git a/{{cookiecutter.project_slug}}/.github/workflows/ci-mutation.yml b/{{cookiecutter.project_slug}}/.github/workflows/ci-mutation.yml new file mode 100644 index 0000000..021033d --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/workflows/ci-mutation.yml @@ -0,0 +1,57 @@ +name: CI Mutation Testing + +on: + pull_request: + branches: [main] + paths: + - "src/**" + - "tests/**" + - "pyproject.toml" + +jobs: + mutmut: + name: mutmut on domain + application + runs-on: ubuntu-latest + env: + DB_URL: sqlite+aiosqlite:///./mutmut.db + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: { enable-caching: true } + - run: uv sync --frozen --group dev + - name: Run mutation testing + run: uv run mutmut run + - name: Show mutmut results + if: always() + run: uv run mutmut results + - name: Enforce mutation score floor + run: | + uv run python -c " + import json + import subprocess + import sys + out = subprocess.check_output(['uv', 'run', 'mutmut', 'results']).decode() + # Heuristic parser; mutmut 3.x prints summary lines like 'killed: N, survived: M'. + killed = survived = 0 + for line in out.splitlines(): + line = line.lower() + if 'killed' in line and ':' in line: + try: + killed = int(line.split(':')[1].split(',')[0].strip()) + except (ValueError, IndexError): + pass + if 'survived' in line and ':' in line: + try: + survived = int(line.split(':')[1].split(',')[0].strip()) + except (ValueError, IndexError): + pass + total = killed + survived + if total == 0: + print('No mutants reported — assuming success.') + sys.exit(0) + score = killed / total + print(f'mutation score: {score:.1%} ({killed}/{total})') + if score < 0.90: + print(f'FAIL: mutation score below 90% threshold.') + sys.exit(1) + " diff --git a/{{cookiecutter.project_slug}}/.github/workflows/ci-quality.yml b/{{cookiecutter.project_slug}}/.github/workflows/ci-quality.yml new file mode 100644 index 0000000..b079fb5 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/workflows/ci-quality.yml @@ -0,0 +1,58 @@ +name: CI Quality + +on: + pull_request: + branches: [main] + +concurrency: + group: ${% raw %}{{ github.workflow }}-${{ github.ref }}{% endraw %} + cancel-in-progress: true + +jobs: + lint-and-types: + name: ruff + ty + custom lints + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: { enable-caching: true } + - run: uv sync --frozen --group dev + - run: uv run ruff check src/ tests/ scripts/ + - run: uv run ruff format --check src/ tests/ scripts/ + - run: uv run python scripts/checks/no_third_party_in_domain.py src/ + - run: uv run python scripts/checks/no_naive_datetime.py src/ + - run: uv run python scripts/checks/test_naming.py tests/ + - name: ty (static type check) + run: uv run ty check src/ + + test-unit: + name: pytest unit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: { enable-caching: true } + - run: uv sync --frozen --group dev + - run: uv run pytest -m unit --cov --cov-report=xml --cov-report=term + + test-integration: + name: pytest integration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: { enable-caching: true } + - run: uv sync --frozen --group dev + - run: uv run pytest -m integration + + test-e2e: + name: pytest e2e + runs-on: ubuntu-latest + env: + DB_URL: sqlite+aiosqlite:///./test.db + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: { enable-caching: true } + - run: uv sync --frozen --group dev + - run: uv run pytest -m e2e diff --git a/.github/workflows/ci-security.yml b/{{cookiecutter.project_slug}}/.github/workflows/ci-security.yml similarity index 50% rename from .github/workflows/ci-security.yml rename to {{cookiecutter.project_slug}}/.github/workflows/ci-security.yml index 84ec6ee..c4e9e78 100644 --- a/.github/workflows/ci-security.yml +++ b/{{cookiecutter.project_slug}}/.github/workflows/ci-security.yml @@ -1,4 +1,4 @@ -name: CI Sécurité +name: CI Security on: pull_request: @@ -8,26 +8,17 @@ jobs: security: name: Bandit + pip-audit + Gitleaks runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - + with: { fetch-depth: 0 } - uses: astral-sh/setup-uv@v7 - with: - enable-caching: true - - - name: Installer les dépendances - run: uv sync --frozen --group dev - - - name: Bandit (SAST) + with: { enable-caching: true } + - run: uv sync --frozen --group dev + - name: Bandit (SAST, high severity / high confidence) run: uv run bandit -r src/ --severity-level high --confidence-level high - - - name: pip-audit (vulnérabilités dépendances) + - name: pip-audit (dependency vulnerabilities) run: uv run pip-audit - - - name: Gitleaks (secrets) + - name: Gitleaks (secrets in history and diff) uses: gitleaks/gitleaks-action@v2 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${% raw %}{{ secrets.GITHUB_TOKEN }}{% endraw %} diff --git a/{{cookiecutter.project_slug}}/.github/workflows/release.yml b/{{cookiecutter.project_slug}}/.github/workflows/release.yml new file mode 100644 index 0000000..ce2b2d8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/workflows/release.yml @@ -0,0 +1,94 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write # publish a GitHub Release + packages: write # push to GHCR + id-token: write # OIDC token used by cosign keyless signing + +jobs: + release: + name: Build, scan, sign, publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + + - uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${% raw %}{{ github.actor }}{% endraw %} + password: ${% raw %}{{ secrets.GITHUB_TOKEN }}{% endraw %} + + - name: Determine version + id: ver + run: echo "version=${% raw %}{{ github.ref_name }}{% endraw %}" | sed 's/v//' >> "$GITHUB_OUTPUT" + + - name: Build and push image (digest captured for cosign) + id: build + uses: docker/build-push-action@v6 + with: + context: . + target: runtime + push: true + tags: | + ghcr.io/${% raw %}{{ github.repository }}{% endraw %}:${% raw %}{{ github.ref_name }}{% endraw %} + ghcr.io/${% raw %}{{ github.repository }}{% endraw %}:latest + + - name: Trivy image scan (release blocker) + uses: aquasecurity/trivy-action@master + with: + image-ref: ghcr.io/${% raw %}{{ github.repository }}{% endraw %}@${% raw %}{{ steps.build.outputs.digest }}{% endraw %} + format: table + exit-code: "1" + severity: HIGH,CRITICAL + ignore-unfixed: true + + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@v0 + with: + image: ghcr.io/${% raw %}{{ github.repository }}{% endraw %}@${% raw %}{{ steps.build.outputs.digest }}{% endraw %} + format: cyclonedx-json + output-file: sbom.cdx.json + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign image (keyless via Sigstore + GitHub OIDC) + env: + COSIGN_EXPERIMENTAL: "true" + run: | + cosign sign --yes \ + ghcr.io/${% raw %}{{ github.repository }}{% endraw %}@${% raw %}{{ steps.build.outputs.digest }}{% endraw %} + + - name: Attach SBOM to the image as an attestation + env: + COSIGN_EXPERIMENTAL: "true" + run: | + cosign attest --yes \ + --predicate sbom.cdx.json \ + --type cyclonedx \ + ghcr.io/${% raw %}{{ github.repository }}{% endraw %}@${% raw %}{{ steps.build.outputs.digest }}{% endraw %} + + - name: Generate changelog + uses: orhun/git-cliff-action@v4 + with: + config: cliff.toml + args: --latest --strip header + env: + OUTPUT: CHANGELOG_TAG.md + continue-on-error: true + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + body_path: CHANGELOG_TAG.md + generate_release_notes: true + files: sbom.cdx.json diff --git a/{{cookiecutter.project_slug}}/.github/workflows/sdk-typescript.yml b/{{cookiecutter.project_slug}}/.github/workflows/sdk-typescript.yml new file mode 100644 index 0000000..d114c91 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.github/workflows/sdk-typescript.yml @@ -0,0 +1,68 @@ +name: SDK TypeScript + +# Generates a TypeScript client SDK from the OpenAPI schema and uploads it +# as a GitHub artefact. Triggered when the API surface or DTOs change. +# +# Extension points commented inline: pushing the SDK to a separate repo or +# publishing to npm with semver bumps. The base workflow stops at artefact +# upload to keep the template's prerequisites minimal (no npm token). + +on: + push: + branches: [main] + paths: + - "src/**/presentation/**" + - "src/**/application/dtos/**" + - "scripts/export_openapi.py" + - ".github/workflows/sdk-typescript.yml" + workflow_dispatch: {} + +jobs: + generate: + name: openapi-generator → typescript-fetch + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v7 + with: { enable-caching: true } + + - run: uv sync --frozen --group dev + + - name: Export OpenAPI schema + run: uv run python scripts/export_openapi.py openapi.json + + - name: Set up Java (openapi-generator runtime) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Generate TypeScript client (typescript-fetch) + uses: openapi-generators/openapitools-generator-action@v1 + with: + generator: typescript-fetch + openapi-file: openapi.json + command-args: -o sdk-typescript --skip-validate-spec + + - name: Upload SDK artefact + uses: actions/upload-artifact@v4 + with: + name: sdk-typescript + path: sdk-typescript/ + retention-days: 90 + + # Optional: push the generated SDK to a separate repo + # - name: Push SDK to client repo + # uses: peaceiris/actions-gh-pages@v4 + # with: + # external_repository: my-org/{{ cookiecutter.project_slug }}-ts-sdk + # publish_dir: ./sdk-typescript + # deploy_key: ${% raw %}{{ secrets.SDK_DEPLOY_KEY }}{% endraw %} + + # Optional: publish to npm + # - uses: actions/setup-node@v4 + # with: { node-version: '20', registry-url: 'https://registry.npmjs.org' } + # - run: cd sdk-typescript && npm publish --access public + # env: + # NODE_AUTH_TOKEN: ${% raw %}{{ secrets.NPM_TOKEN }}{% endraw %} diff --git a/{{cookiecutter.project_slug}}/.gitignore b/{{cookiecutter.project_slug}}/.gitignore new file mode 100644 index 0000000..2742f3d --- /dev/null +++ b/{{cookiecutter.project_slug}}/.gitignore @@ -0,0 +1,59 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ + +# uv +.uv/ + +# Testing / coverage +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +coverage.xml +.mutmut-cache +mutants/ + +# Type checking +.mypy_cache/ +.ty_cache/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.* +!.env.example + +# Local SQLite dev database +local.db +local.db-journal +local.db-wal +local.db-shm + +# Logs +*.log + +# Helm +helm/*/charts/ +helm/*/Chart.lock diff --git a/{{cookiecutter.project_slug}}/.pre-commit-config.yaml b/{{cookiecutter.project_slug}}/.pre-commit-config.yaml new file mode 100644 index 0000000..80223b1 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.pre-commit-config.yaml @@ -0,0 +1,68 @@ +# Pre-commit configuration for the generated project. +# +# The aggregated quality script (scripts/checks/quality.sh) is the single +# source of truth invoked by pre-commit, by .claude/hooks/pre-commit-gate.sh +# and by ci-quality.yml. If you add or change a check, do it in that script. + +default_language_version: + python: python{{ cookiecutter.python_version }} + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: detect-private-key + - id: check-added-large-files + - id: check-merge-conflict + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.12 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/zricethezav/gitleaks + rev: v8.21.2 + hooks: + - id: gitleaks + + - repo: https://github.com/commitizen-tools/commitizen + rev: v4.1.0 + hooks: + - id: commitizen + stages: [commit-msg] + + - repo: local + hooks: + - id: no-third-party-in-domain + name: no third-party imports in domain layer + entry: uv run python scripts/checks/no_third_party_in_domain.py src/ + language: system + pass_filenames: false + files: ^src/.*/domain/.*\.py$ + + - id: no-naive-datetime + name: no naive datetime.now() outside tests + entry: uv run python scripts/checks/no_naive_datetime.py src/ + language: system + pass_filenames: false + files: ^src/.*\.py$ + + - id: test-naming + name: GIVEN/WHEN/THEN test format + entry: uv run python scripts/checks/test_naming.py tests/ + language: system + pass_filenames: false + files: ^tests/.*\.py$ + + - id: pytest-unit-pre-push + name: pytest unit (pre-push only) + entry: uv run pytest -m unit -x -q + language: system + pass_filenames: false + stages: [pre-push] diff --git a/{{cookiecutter.project_slug}}/.python-version b/{{cookiecutter.project_slug}}/.python-version new file mode 100644 index 0000000..102fdf4 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.python-version @@ -0,0 +1 @@ +{{ cookiecutter.python_version }} diff --git a/{{cookiecutter.project_slug}}/CLAUDE.md b/{{cookiecutter.project_slug}}/CLAUDE.md new file mode 100644 index 0000000..b1ba307 --- /dev/null +++ b/{{cookiecutter.project_slug}}/CLAUDE.md @@ -0,0 +1,69 @@ +# {{ cookiecutter.project_name }} — AI Rules + +> This file is loaded automatically at the start of every Claude Code session. +> It is the routing table to `.claude/skills/`. Do not put long content here. + +## Project context + +{{ cookiecutter.project_description }} + +Stack: Python {{ cookiecutter.python_version }}, FastAPI, SQLAlchemy 2.0 async, Alembic, structlog, OpenTelemetry. Architecture: Clean Architecture (Uncle Bob strict) with four layers — `domain`, `application`, `infrastructure`, `presentation`. + +The reference feature shipped in `src/` and `tests/` is the SSO-backed `GET /v1/users/me` flow: a verified JWT → `EnsureUserExistsUseCase` (idempotent find-or-create on the `subject` claim) → `SqlAlchemyUserRepository` → `users` table. The first authenticated call lazily provisions the row; subsequent calls reuse it. Read the `onboarding-soma` skill for the file map and `docs/swagger-oauth2.md` for the Azure / Auth0 / Keycloak app-registration walkthrough. + +## When you do X, use skill Y + +| Trigger | Skill | +| ------------------------------------------------------------------ | ------------------------------ | +| First session on this repo | `onboarding-soma` | +| "Where does this code go?", import questions, naming, layer rules | `onboarding-soma` | +| `infrastructure/container.py`, `dependencies/`, observability | `onboarding-soma` | +| Editing `src//domain/`, new entity, value object, exception | `writing-domain-code` | +| "Add a feature", "new use case", "new endpoint" | `building-a-feature` | +| "Need a new EmailSender / Gateway / Hasher / ..." | `building-a-feature` (§ "Adding a new abstraction") | +| "Test an httpx adapter", external SaaS (Stripe / Twilio / OIDC ..) | `building-a-feature` (§ "Testing external HTTP adapters") | +| Domain events, transactions, outbox | `building-a-feature` (§ "Transactions and events") | +| "Idempotency-Key", "retry safe", 409/422 idempotency | `building-a-feature` (§ "Idempotency") | +| "Fix a bug", "write a test", editing `tests/fakes/` | `tdd-workflow` | +| "Migration", "schema change", "alembic" | `database-and-migrations` | +| Editing `helm/` | `writing-a-helm-change` | +| "Protect a route", "JWT", role check, "401/403" | `adding-auth` | +| "Event-source X", "should we ES this entity?", audit / time-travel | `event-sourcing-pattern` | +| "Review this PR" | `reviewing-a-pr-soma-style` | +| Operator runbooks (incident, backup, restore) | `docs/runbooks/` | + +## Hard rules (non-negotiable) + +1. **TDD red first**. Every line of production code is justified by a failing test. +2. **No mocks in unit tests** — only Fakes from `tests/fakes/`. Mocks only for non-self-hostable external SaaS, in integration tests. +3. **No third-party imports in `src//domain/`** — not even `datetime.now()`, `uuid.uuid4()`, `random.*`. Use the `Clock`, `IdGenerator`, `RandomSource` abstractions. +4. **No log in `domain/` or `application/`** — log only at infrastructure and presentation boundaries. +5. **No HTTP types in `application/` or `domain/`** — exceptions are `DomainError` subclasses; the HTTP mapping lives in `presentation/api/error_handlers.py`. +6. **GIVEN/WHEN/THEN strict** in unit and integration tests (function name and body comments). One assertion per test, no `@pytest.mark.parametrize`. +7. **Conventional commits**, no `--no-verify`, no force push. +8. **Function ≤ 25 lines**. Cyclomatic complexity ≤ 10. +9. **`Decimal` for money**, **TZ-aware datetimes** stored UTC. +10. **No `Manager`, `Helper`, `Util`, `Service`** generic class names. + +## How the main agent works on this repo + +This project ships **no custom sub-agents**. The main agent handles the whole flow directly, guided by skills. For a feature: + +1. The user gives a one-liner prompt. +2. The main agent asks 4-6 clarification questions (picked from the packs in `building-a-feature`). +3. The user answers. +4. The main agent produces the 5-section outside-in plan. +5. **MANDATORY HARD GATE — the user validates.** After posting the plan, the agent **STOPS** and waits for an **explicit affirmative** ("go", "ok", "validé", …). Silence, clarifying questions, and partial acknowledgements are **NOT** approval. No `Edit`/`Write`/`git` calls until the user explicitly unblocks. See `building-a-feature` §4 for the full discipline. +6. The main agent executes outside-in TDD: use case (unit) → persistence (integration) → route (e2e), wires the container, runs mutation testing. +7. Once the suite is green and lint passes, the main agent **commits the work itself** as one conventional-commit (`feat(): …` / `fix(): …`). No `--no-verify`. The user reviews the commit, not the working tree. + +The full runbook is in `building-a-feature`. The discipline (red-green-refactor, Fakes-only, GIVEN/WHEN/THEN, mutation) is in `tdd-workflow`. The "how SOMA code is shaped" reference (layers, naming, DI, observability) is in `onboarding-soma`. + +PR review uses Anthropic's built-in `pr-review-toolkit:*` agents plus the `reviewing-a-pr-soma-style` skill. + +## Pointers + +- Architecture overview: [`docs/architecture.md`](docs/architecture.md) +- ADRs: [`docs/adr/`](docs/adr/) +- Operator runbooks: [`docs/runbooks/`](docs/runbooks/) — backups, restore, disaster-recovery playbooks +- Reference feature file map: see the `onboarding-soma` skill diff --git a/{{cookiecutter.project_slug}}/Dockerfile b/{{cookiecutter.project_slug}}/Dockerfile new file mode 100644 index 0000000..63a75ea --- /dev/null +++ b/{{cookiecutter.project_slug}}/Dockerfile @@ -0,0 +1,54 @@ +# Production image — used by the Helm chart. +# Multi-stage Pattern 1: builder installs deps, runtime ships only the venv + +# the application code. The development image lives separately under +# .devcontainer/ to keep this file focused on prod concerns. + +ARG PYTHON_VERSION={{ cookiecutter.python_version }} + +# ─── builder ──────────────────────────────────────── +FROM python:${PYTHON_VERSION}-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ + +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +# ─── runtime ──────────────────────────────────────── +FROM python:${PYTHON_VERSION}-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/app/.venv/bin:$PATH" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends tini ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1001 app \ + && useradd --system --uid 1001 --gid app --no-create-home app + +WORKDIR /app +COPY --from=builder /app/.venv /app/.venv +COPY --chown=app:app src/ /app/src/ +COPY --chown=app:app alembic/ /app/alembic/ +COPY --chown=app:app alembic.ini /app/ + +USER app +EXPOSE 8000 + +ENTRYPOINT ["tini", "--"] +# --timeout-graceful-shutdown 30 lets uvicorn drain in-flight requests for up +# to 30s after SIGTERM. The Helm chart's terminationGracePeriodSeconds MUST +# stay strictly greater than 30 for the drain to complete before SIGKILL. +CMD ["uvicorn", "{{ cookiecutter.package_name }}.presentation.api.app:app", \ + "--host", "0.0.0.0", "--port", "8000", \ + "--timeout-graceful-shutdown", "30"] diff --git a/{{cookiecutter.project_slug}}/LICENSE b/{{cookiecutter.project_slug}}/LICENSE new file mode 100644 index 0000000..9eebdf5 --- /dev/null +++ b/{{cookiecutter.project_slug}}/LICENSE @@ -0,0 +1,64 @@ +{% if cookiecutter.license == "MIT" -%} +MIT License + +Copyright (c) 2026 {{ cookiecutter.author_name }} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +{%- elif cookiecutter.license == "Apache-2.0" -%} + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + Copyright (c) 2026 {{ cookiecutter.author_name }} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + The full text of the Apache License 2.0 is available at the URL above. + By using, modifying, or redistributing this software, you agree to the + full terms of that license. +{%- else -%} +Proprietary Software License + +Copyright (c) 2026 {{ cookiecutter.author_name }}. All rights reserved. + +This software and its source code are the confidential and proprietary +property of {{ cookiecutter.author_name }}. No part of this codebase may be +copied, reproduced, distributed, transmitted, modified, sublicensed, sold, +or otherwise used outside the engagement under which it was provided, +without the prior written consent of {{ cookiecutter.author_name }}. + +Unauthorised use, reproduction, or distribution of this software, or any +portion of it, may result in severe civil and criminal penalties, and will +be prosecuted to the maximum extent possible under the law. + +THIS SOFTWARE IS PROVIDED BY {{ cookiecutter.author_name }} "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. +{%- endif %} diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md new file mode 100644 index 0000000..7dd4a73 --- /dev/null +++ b/{{cookiecutter.project_slug}}/README.md @@ -0,0 +1,101 @@ +# {{ cookiecutter.project_name }} + +> {{ cookiecutter.project_description }} + +## Stack + +- Python {{ cookiecutter.python_version }} • FastAPI • SQLAlchemy 2.0 async • Alembic +- Clean Architecture (Uncle Bob strict) — see [`docs/architecture.md`](docs/architecture.md) +- structlog + OpenTelemetry • uv • ruff • ty • mutmut + +## Five commands you need + +The project uses [`just`](https://just.systems) as a command runner — a thin +shell over `uv run …`. The devcontainer ships it pre-installed; on bare metal +install it once with `cargo install just` / `brew install just` / `apt install just`. + +```bash +just install # uv sync --group dev +just dev # uvicorn with auto-reload +just test # fast unit tests +just test-all # unit + integration + e2e +just mutate # mutation testing on domain + application +``` + +Type `just` (no args) to list every recipe. Each one is one line in `justfile` +— inspect it instead of memorising flags. +{%- if cookiecutter.frontend_sdk == "typescript" %} + +A TypeScript client SDK is generated automatically by `.github/workflows/sdk-typescript.yml` +on every push to `main` that touches the API surface; it is uploaded as a workflow +artefact. Run `just openapi` locally to dump the OpenAPI schema to `openapi.json`. +{%- endif %} + +## First time on this project + +1. Open in VS Code and run **Reopen in Container** — Postgres + Jaeger + OTel collector start automatically, and the Swagger UI opens in a preview pane. Several projects baked from this template can coexist on the same machine (no fixed host port bindings — VS Code's Ports tab handles forwarding and picks free local ports automatically). +2. Read [`CLAUDE.md`](CLAUDE.md) (AI rules) and [`docs/architecture.md`](docs/architecture.md). +3. Browse the **User reference feature** shipped in `src/` and `tests/` — it threads the four layers end-to-end (`GET /v1/users/me` → JWT `get_current_user` → `EnsureUserExistsUseCase` (idempotent find-or-create on the `subject` claim) → `SqlAlchemyUserRepository` → `users` table). It is real working code: keep it as a starting point if your service has the notion of a user; remove it cleanly otherwise (see below). The IdP integration walkthrough lives in [`docs/swagger-oauth2.md`](docs/swagger-oauth2.md). +4. Add your first feature using the `building-a-feature` Claude skill. + +### Removing the reference feature + +Run from the project root, then commit: + +```bash +# Source files (12) +git rm src/{{ cookiecutter.package_name }}/domain/entities/user.py \ + src/{{ cookiecutter.package_name }}/domain/value_objects/email.py \ + src/{{ cookiecutter.package_name }}/domain/value_objects/user_id.py \ + src/{{ cookiecutter.package_name }}/domain/exceptions/user.py \ + src/{{ cookiecutter.package_name }}/application/repositories/user.py \ + src/{{ cookiecutter.package_name }}/application/use_cases/ensure_user_exists.py \ + src/{{ cookiecutter.package_name }}/application/dtos/user.py \ + src/{{ cookiecutter.package_name }}/infrastructure/persistence/models/user.py \ + src/{{ cookiecutter.package_name }}/infrastructure/persistence/user_repository.py \ + src/{{ cookiecutter.package_name }}/presentation/api/v1/users.py \ + src/{{ cookiecutter.package_name }}/presentation/api/schemas/user.py \ + src/{{ cookiecutter.package_name }}/presentation/api/dependencies/users.py + +# Tests (6) +git rm tests/unit/domain/test_user.py \ + tests/unit/domain/test_email.py \ + tests/unit/use_cases/test_ensure_user_exists.py \ + tests/e2e/api/test_users_me_endpoint.py \ + tests/integration/infrastructure/test_user_repository.py \ + tests/fakes/repositories/user.py +``` + +Then **edit five files** to drop the dangling references: + +| File | Drop | +| --- | --- | +| `tests/conftest.py` | the `user_repository` and `ensure_user_exists_use_case` fixtures, and the `app.dependency_overrides[get_ensure_user_exists_use_case]` / `get_or_provision_user` lines | +| `src/{{ cookiecutter.package_name }}/presentation/api/error_handlers.py` | the `UserAlreadyExistsError`, `UserNotFoundError`, `InvalidEmailError`, `MissingProfileClaimsError` imports and entries in `ERROR_HTTP_MAPPING` | +| `src/{{ cookiecutter.package_name }}/infrastructure/container.py` | `build_user_repository`, `build_ensure_user_exists_use_case`, and their imports | +| `src/{{ cookiecutter.package_name }}/presentation/api/app.py` | the `users_v1` import and `app.include_router(users_v1.router)` line | +| `alembic/versions/0001_init.py` | the `users` block in `upgrade()` (table + the two unique indexes) and the matching lines in `downgrade()` — the file also creates `outbox_events` and `idempotency_records`, leave those alone | + +Sanity-check your removal: + +```bash +just lint # ruff + ty + 4 architectural lints +just test-all # everything that remained should still pass +``` + +The base classes (`Entity`, `ValueObject`, `UseCase`, `Repository`), the abstractions (`Clock`, `IdGenerator`, `EventBus`, `AuditLog`, `OutboxEventModel`), the middleware stack, the health probes, the conftest primitives (`frozen_clock`, `sequential_ids`, `seeded_random`, `stub_session`) — all stay and are reusable for your real domain. + +## Conventions (non-negotiable) + +- **TDD**: red → green → refactor → mutate. No production code without a failing test first. +- **Clean Architecture layering**: `domain` knows nothing; `application` knows `domain`; `infrastructure` and `presentation` know inward. +- **Tests**: GIVEN/WHEN/THEN strict naming and body. Fakes in unit tests; testcontainers in integration; one happy path + one critical error per endpoint in e2e. +- **Conventional Commits**, mandatory PRs, no `--no-verify`, no force push. + +The full ruleset lives in `.claude/skills/` and is enforced by pre-commit, git hooks, Claude Code hooks and CI. + +## CI/CD + +GitHub Actions: quality (ruff + ty + pytest), security (bandit + pip-audit + gitleaks), mutation (mutmut), helm (lint + kubeconform + polaris), migration-drift (autogenerate diff), image-scan (Trivy + SBOM), release (build + scan + cosign sign + SBOM attestation). + +Conventional Commits are enforced **locally** by the `commitizen` `commit-msg` pre-commit hook (and Claude Code permissions block `--no-verify`), so a dedicated CI gate is intentionally absent. diff --git a/{{cookiecutter.project_slug}}/SECURITY.md b/{{cookiecutter.project_slug}}/SECURITY.md new file mode 100644 index 0000000..8df4dcb --- /dev/null +++ b/{{cookiecutter.project_slug}}/SECURITY.md @@ -0,0 +1,49 @@ +# Security policy + +## Reporting a vulnerability + +If you have found a security issue in {{ cookiecutter.project_name }}, **please do not open a public GitHub issue**. Send a private report instead: + +- **Email**: {{ cookiecutter.author_email }} (subject: `SECURITY: `) +- Or use the GitHub *"Report a vulnerability"* button on this repository's *Security* tab (Private vulnerability reporting). + +Include in your report: + +- A clear description of the vulnerability and the impact you observed. +- A minimal reproducer (request payload, sequence of operations, environment). +- The commit hash or release version where you discovered the issue. +- Optional: a proposed mitigation, if you have one in mind. + +## Coordinated disclosure + +We commit to: + +1. **Acknowledge** receipt within **3 business days**. +2. Provide a **first technical assessment** within **10 business days** — severity, affected versions, expected fix timeline. +3. Coordinate a **fix and disclosure date**. We aim to ship a fix within **30 days** of assessment for high-severity issues; we will keep you informed of any delay. +4. **Credit** you in the changelog and the GitHub Security Advisory unless you ask to remain anonymous. + +Please give us a reasonable opportunity to fix the issue before any public disclosure. We do not have a bug bounty programme, but we are happy to discuss recognition on a case-by-case basis. + +## Supported versions + +| Version | Status | +| --- | --- | +| `main` | Actively supported. Security patches are released as soon as a fix is ready. | +| Tagged releases | Only the latest tagged minor (`vX.Y.*`) receives security patches. Older minors require an upgrade. | + +## Out of scope + +The following are intentionally **not** treated as security issues by this project: + +- Vulnerabilities in dependencies that already have a published fix and a Dependabot PR open against this repo (please review the open PR rather than file a new report). +- Self-inflicted misconfigurations (e.g. running with `DB_URL` pointing to a public Postgres without network restrictions). +- Findings on a fork that has materially diverged from upstream. + +## Hardening checklist for downstream operators + +- Run only signed images (verify with `cosign verify` — see `docs/architecture.md`). +- Pin to specific release tags, never `:latest`, in production. +- Subscribe to GitHub *Security advisories* for this repository. +- Rotate all secrets injected via `Settings` (DB credentials, API tokens) regularly. +- Keep the Helm chart's `securityContext` defaults intact (`runAsNonRoot`, dropped capabilities, read-only root filesystem). diff --git a/{{cookiecutter.project_slug}}/alembic.ini b/{{cookiecutter.project_slug}}/alembic.ini new file mode 100644 index 0000000..ce537a2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = ${DB_URL} + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/{{cookiecutter.project_slug}}/alembic/env.py b/{{cookiecutter.project_slug}}/alembic/env.py new file mode 100644 index 0000000..e54b680 --- /dev/null +++ b/{{cookiecutter.project_slug}}/alembic/env.py @@ -0,0 +1,83 @@ +"""Alembic environment for async SQLAlchemy.""" + +from __future__ import annotations + +import asyncio +import os +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from {{ cookiecutter.package_name }}.infrastructure.persistence.models import Base + + +def _load_dotenv() -> None: + """Inject ``.env`` values into ``os.environ`` if not already set. + + Avoids the dependency on ``python-dotenv`` for a 30-line shell-style parser. + Called before alembic reads ``DB_URL`` so that ``uv run alembic ...`` works + out of the box without the user manually sourcing ``.env``. + """ + env_file = Path(__file__).resolve().parent.parent / ".env" + if not env_file.is_file(): + return + for raw in env_file.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + + +_load_dotenv() + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", os.environ.get("DB_URL", config.get_main_option("sqlalchemy.url", ""))) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations against the URL only, no actual DB connection.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/{{cookiecutter.project_slug}}/alembic/script.py.mako b/{{cookiecutter.project_slug}}/alembic/script.py.mako new file mode 100644 index 0000000..1e1f0ae --- /dev/null +++ b/{{cookiecutter.project_slug}}/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "raise NotImplementedError('Downgrade not implemented for this revision.')"} diff --git a/{{cookiecutter.project_slug}}/alembic/versions/.gitkeep b/{{cookiecutter.project_slug}}/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/alembic/versions/0001_init.py b/{{cookiecutter.project_slug}}/alembic/versions/0001_init.py new file mode 100644 index 0000000..56ebfba --- /dev/null +++ b/{{cookiecutter.project_slug}}/alembic/versions/0001_init.py @@ -0,0 +1,114 @@ +"""init + +Revision ID: 0001_init +Revises: +Create Date: 2026-05-29 + +Initial schema shipped with the template. Creates the three tables backing +the features wired by default: + +* ``users`` — SSO auto-provisioned identities (``EnsureUserExistsUseCase``). + Keyed by an internal UUID ``id``, tracked by the IdP's ``subject`` claim + (unique). ``email`` is also unique so two SSO users cannot share an address. +* ``outbox_events`` — durable buffer for ``SqlOutboxEventBus``. Each row is + written inside the request transaction; the relay worker + (``infrastructure/jobs/outbox_relay.py``) drains it asynchronously and + uses ``status`` + ``next_attempt_at`` to retry / poison-park failing rows. +* ``idempotency_records`` — per-key state for the IdempotencyMiddleware + so retries return the original response instead of re-running the handler. + +Drop the tables a project does not need (and the matching +``…Model`` / Fakes / use cases) — see the per-table notes below. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0001_init" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create ``users``, ``outbox_events`` and ``idempotency_records``.""" + # ─── users ────────────────────────────────────────────────────── + # Index names mirror SQLAlchemy's ``unique=True, index=True`` auto-naming + # (``ix__``) so ``alembic revision --autogenerate`` + # reports no drift on a freshly baked project. + op.create_table( + "users", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("subject", sa.String(length=255), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_users_subject", "users", ["subject"], unique=True) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + # ─── outbox_events ────────────────────────────────────────────── + # ``attempts`` / ``status`` get server_defaults to match the model + # invariants; the polling index supports the relay's WHERE on + # ``status = 'pending' AND next_attempt_at <= now()``. + op.create_table( + "outbox_events", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("event_name", sa.String(length=255), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False, server_default=sa.text("'pending'")), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_outbox_events_event_name", "outbox_events", ["event_name"]) + op.create_index("ix_outbox_events_created_at", "outbox_events", ["created_at"]) + op.create_index("ix_outbox_events_published_at", "outbox_events", ["published_at"]) + op.create_index( + "ix_outbox_events_status_next_attempt", + "outbox_events", + ["status", "next_attempt_at"], + ) + + # ─── idempotency_records ──────────────────────────────────────── + # Lazy cleanup at lookup time: indexed for the WHERE ``expires_at < now()`` pass. + op.create_table( + "idempotency_records", + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("method", sa.String(length=16), nullable=False), + sa.Column("path", sa.String(length=2048), nullable=False), + sa.Column("response_status", sa.Integer(), nullable=True), + sa.Column("response_body", sa.JSON(), nullable=True), + sa.Column("response_headers", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("key"), + ) + op.create_index("ix_idempotency_records_expires_at", "idempotency_records", ["expires_at"]) + + +def downgrade() -> None: + """Drop the three tables and their indexes.""" + op.drop_index("ix_idempotency_records_expires_at", table_name="idempotency_records") + op.drop_table("idempotency_records") + + op.drop_index("ix_outbox_events_status_next_attempt", table_name="outbox_events") + op.drop_index("ix_outbox_events_published_at", table_name="outbox_events") + op.drop_index("ix_outbox_events_created_at", table_name="outbox_events") + op.drop_index("ix_outbox_events_event_name", table_name="outbox_events") + op.drop_table("outbox_events") + + op.drop_index("ix_users_email", table_name="users") + op.drop_index("ix_users_subject", table_name="users") + op.drop_table("users") diff --git a/{{cookiecutter.project_slug}}/docs/adr/0001-clean-architecture.md b/{{cookiecutter.project_slug}}/docs/adr/0001-clean-architecture.md new file mode 100644 index 0000000..a6a3081 --- /dev/null +++ b/{{cookiecutter.project_slug}}/docs/adr/0001-clean-architecture.md @@ -0,0 +1,41 @@ +# 1. Clean Architecture as the project skeleton + +Date: 2026-05-05 + +## Status + +Accepted. + +## Context + +We need a structural pattern that: + +1. Keeps business rules independent from frameworks (FastAPI, SQLAlchemy, Pydantic, structlog) so they can evolve at their own pace. +2. Keeps the domain logic testable without I/O — fast unit tests, deterministic outcomes. +3. Has a clear "where do I put this code" answer for every kind of change. +4. Is recognised broadly enough that a new SOMA developer (or Claude) can map their existing knowledge onto it. + +## Decision + +We adopt **Clean Architecture** (Uncle Bob, strict variant) with four layers: `domain`, `application`, `infrastructure`, `presentation`. The dependency rule is one-way and inward: outer layers know about inner layers, never the reverse. + +Specifically: + +- `domain` is pure: stdlib only, no `datetime.now()`, no `uuid.uuid4()`, no `random.*`. Non-deterministic side effects are abstracted (`Clock`, `IdGenerator`, `RandomSource`). +- `application` exposes use cases (`…UseCase` classes with an `execute()` method) and the abstractions they depend on. It imports `domain` only. +- `infrastructure` provides concrete implementations of the abstractions — SQLAlchemy repositories, structlog setup, OTel setup, the DI container — and imports both `application` and `domain`. +- `presentation` exposes FastAPI routes, Pydantic schemas, error handlers. It imports `application` and `domain` for types but **never** `infrastructure`. Concretes flow in via the container exposed through `Depends`. + +Naming follows Uncle Bob *Clean Code*: no `Manager`/`Helper`/`Service`/`Util`, role-stereotype suffixes (`Repository`, `Gateway`, `Bus`, `Sender`, …) replace the generic `Port` suffix, and the `…UseCase` suffix is kept as a deliberate role marker. + +## Consequences + +**Positive**: +- Business rules survive a framework migration unchanged. +- Unit tests on `application/` use Fakes only — fast, deterministic, run in milliseconds. +- A new dev only has to learn four buckets to know where everything is. +- Cross-layer leaks are detectable mechanically (`scripts/checks/no_third_party_in_domain.py`). + +**Negative**: +- More files than a flat layout. The `User` feature spans ~10 files (entity, value object, exception, repo abstract, repo impl, use case, DTOs, schema, route, mapping). The team has decided this is a worthwhile tax. +- Mapping between domain entities and ORM models adds boilerplate. Mitigated by living strictly inside `infrastructure/persistence/` repositories, with private `_to_entity` / `_to_model` helpers. diff --git a/{{cookiecutter.project_slug}}/docs/adr/0002-tdd-and-mutation-testing.md b/{{cookiecutter.project_slug}}/docs/adr/0002-tdd-and-mutation-testing.md new file mode 100644 index 0000000..26e2226 --- /dev/null +++ b/{{cookiecutter.project_slug}}/docs/adr/0002-tdd-and-mutation-testing.md @@ -0,0 +1,40 @@ +# 2. TDD red-green-refactor + mutation testing + +Date: 2026-05-05 + +## Status + +Accepted. + +## Context + +Coverage % is a misleading proxy for test quality: 100% line coverage with weak assertions still ships bugs. We need a discipline that produces tests strong enough to fail when the production behaviour is wrong, not just "exercised". + +## Decision + +We follow **strict TDD** (red → green → refactor) with **mutation testing** as a mandatory fourth step on `domain/` and `application/` changes: + +1. **Red**: write a failing unit test for the smallest behaviour. The test must fail for the right reason (assertion, not import error). +2. **Green**: write the minimum production code to pass. +3. **Refactor**: clean up while the bar is green. +4. **Mutate**: `uv run mutmut run --paths-to-mutate src//` on the touched code. Mutants surviving ⇒ tests too lax ⇒ back to step 1 with a new failing test. + +CI enforces: +- Coverage ≥ 95% on `domain/` and `application/`. +- Mutation score ≥ 90% on the same. +- A weekly Monday cron that re-runs mutmut on `main` and opens an issue if the score drifts. + +The skill `tdd-workflow` describes the loop and is referenced from `CLAUDE.md`. + +## Consequences + +**Positive**: +- Tests describe behaviour, not implementation. Refactors stay safe. +- The 90% mutation floor catches assertion gaps that pure coverage cannot. +- Onboarding new developers and AI assistants is easier — the rule is explicit and machine-checked. + +**Negative**: +- Mutation testing is slow on large code bases. We mitigate by: + - Scoping `paths_to_mutate` to `domain` + `application` (the layers with logic worth mutating). + - Excluding `infrastructure/` and `presentation/` (mostly glue / serialisation, low signal). +- The discipline has a learning curve. We compensate with the reference feature in `examples/feature_user_signup/` and the `tdd-workflow` skill. diff --git a/{{cookiecutter.project_slug}}/docs/adr/0003-fakes-only-in-unit-tests.md b/{{cookiecutter.project_slug}}/docs/adr/0003-fakes-only-in-unit-tests.md new file mode 100644 index 0000000..632ff4b --- /dev/null +++ b/{{cookiecutter.project_slug}}/docs/adr/0003-fakes-only-in-unit-tests.md @@ -0,0 +1,41 @@ +# 3. Fakes only in unit tests (no Mock) + +Date: 2026-05-05 + +## Status + +Accepted. + +## Context + +`unittest.mock` is convenient but couples tests to implementation details. A mock-heavy test suite passes after a refactor that broke production behaviour, simply because the mock chain still matches. The team has been bitten enough times to ban Mocks at the unit-test level. + +We also want a single, explicit answer to the recurring question "what test double should I use here?". + +## Decision + +In `tests/unit/`: + +- **Fakes only.** Each abstraction in `application/` ships a Fake under `tests/fakes/`. The Fake is a working in-memory implementation (e.g., `InMemoryUserRepository`), simpler than the real binding but faithful enough to enforce the same invariants (uniqueness, validation). +- `unittest.mock`, `Mock`, `MagicMock`, manually written Stubs, `unittest.mock.PropertyMock` are **forbidden** in `tests/unit/`. +- When a test needs to assert that a side effect happened, the Fake exposes its internal state (e.g., `repo.users` dict, `email_sender.sent` list) — this is the role a Spy would play, expressed as observable Fake state instead of a separate doubling mechanism. + +In `tests/integration/`: + +- **Real systems via `testcontainers`** (Postgres, Redis, etc.) — anything self-hostable. +- **Mocks ONLY for non-self-hostable third-party services** — paid SaaS sandbox APIs that cannot be replicated locally. Document the choice in a comment. + +In `tests/e2e/`: + +- The whole stack is real (FastAPI booted, test database). Tests prove wiring, not behaviour. **1 happy path + 1 critical error per endpoint** — branches stay covered at the unit level. + +## Consequences + +**Positive**: +- Tests survive refactors that change "how" without changing "what". +- The Fake of an abstraction doubles as living documentation of its contract. +- The cognitive load is lower: no `Mock` magic to learn, just call `InMemoryUserRepository().users` and `assert`. + +**Negative**: +- Writing the first Fake of an abstraction is more work than `Mock(spec=…)`. We treat this as a feature: it forces us to think about the contract. +- Some patterns (interaction-heavy code with many collaborators) suggest Mocks would be easier — but our Clean Architecture layout makes interaction-heavy use cases rare. When they do appear, the smell is usually a missing Fake or a misplaced collaborator. diff --git a/{{cookiecutter.project_slug}}/docs/adr/0004-transaction-boundary.md b/{{cookiecutter.project_slug}}/docs/adr/0004-transaction-boundary.md new file mode 100644 index 0000000..b41b1f2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/docs/adr/0004-transaction-boundary.md @@ -0,0 +1,47 @@ +# 4. Transaction boundary aligned with the HTTP request + +Date: 2026-05-06 + +## Status + +Accepted. + +## Context + +A use case may issue several writes (insert the user, insert the audit row, insert the outbox event). Three places could own the commit: + +1. **Repository** — `add()` calls `commit()` after `flush()`. Couples the repo to the lifecycle, makes multi-write use cases impossible to keep atomic, and breaks the dependency direction (the repo does not know whether the use case has more work to do). +2. **Use case** — the use case calls `commit()` at the end of `execute()`. Forces every use case to know about persistence, leaks an infrastructure concern into `application/`. +3. **Request boundary** — the FastAPI dependency that produces the session also opens a transaction. Commit on successful return; rollback on any raised exception. + +We took option 3. + +## Decision + +`presentation/api/dependencies/common.py::get_session` opens **both** a session and a transaction: + +```python +async def get_session(request: Request) -> AsyncIterator[AsyncSession]: + factory = request.app.state.session_factory + async with factory() as session, session.begin(): + yield session +``` + +Consequences for downstream code: + +- **Repositories** call `flush()` to surface integrity errors immediately (e.g. `UserAlreadyExistsError`) but never `commit()`. They translate `IntegrityError` to a `DomainError` and re-raise. +- **Use cases** never see transactions. They issue domain operations through the repository and let exceptions bubble. +- **Route handlers** and the use case itself can raise `DomainError` freely — the dependency wrapper rolls back, the registered exception handler converts to the HTTP error response. +- **A single HTTP request = a single transaction.** A use case that needs multiple independent transactions either issues sub-transactions via `session.begin_nested()` (savepoints) or, for cross-process orchestration, persists outbox events that a worker consumes (see ADR 0005). + +## Consequences + +**Positive**: +- Correctness by default. The previous code path opened a session without `begin()` — writes were silently rolled back at session close. This rewrite fixes that latent bug. +- Atomicity matches user intent: the user either created with everything that hangs off it, or nothing at all. +- The use case stays storage-agnostic. + +**Negative**: +- A long-running use case holds the transaction open for its whole duration; we mitigate by capping every request at `request_timeout_seconds` (default 30s) and by keeping use cases thin. +- Read-only endpoints pay a tiny cost opening a transaction they won't use. SQLAlchemy elides empty transactions cheaply, so we accept it. +- Tests that override `get_session` must produce an object that supports `async with session.begin():`. The `StubAsyncSession` shipped in `tests/conftest.py` is fine because the override replaces the whole dependency, not just the inner session. \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/docs/adr/0005-outbox-pattern.md b/{{cookiecutter.project_slug}}/docs/adr/0005-outbox-pattern.md new file mode 100644 index 0000000..4d0e801 --- /dev/null +++ b/{{cookiecutter.project_slug}}/docs/adr/0005-outbox-pattern.md @@ -0,0 +1,38 @@ +# 5. Domain events go through a SQL outbox + +Date: 2026-05-06 + +## Status + +Accepted. + +## Context + +When a use case writes an entity **and** wants to publish a domain event ("user signed up", "order placed"), the naive implementation calls the broker directly inside the use case. Two failure modes follow: + +1. The DB write succeeds, the broker call fails → consumers never see the event, the system is silently inconsistent. +2. The broker call succeeds, the DB write rolls back → consumers see an event for an entity that does not exist. + +A reliable distributed message bus (Kafka transactional, two-phase commit) sidesteps the issue but ties the project to one broker and adds operational complexity. + +## Decision + +We adopt the **transactional outbox** pattern: + +* `application/event_bus.py::EventBus` is the publisher protocol used by use cases. +* `infrastructure/outbox.py::SqlOutboxEventBus` is the production binding. It writes a row to ``outbox_events`` using the same `AsyncSession` as the entity write — both rows hit the DB inside the request transaction (ADR 0004). +* `infrastructure/jobs/outbox_relay.py` is a separate process. It polls `published_at IS NULL`, calls a project-specific `_publish_event(...)` function, and marks the row published only after the broker durably acknowledges. + +The entity write and the outbox write commit or roll back together. Consumers see the event **iff** the entity exists. Delivery is at-least-once; consumers must deduplicate on the row `id`. + +## Consequences + +**Positive**: +- Atomic event publication. No silent inconsistency. +- Broker-agnostic: the relay's `_publish_event` is the only project-specific piece. Switch from RabbitMQ to Kafka without touching `application/`. +- Observability for free: the table is the audit trail. A row stuck unpublished for more than N minutes is a paging condition. + +**Negative**: +- Polling latency. The relay polls every second; events are not strictly real-time. Acceptable for most business events; if sub-second matters, replace polling with Postgres `LISTEN/NOTIFY` or a CDC stream. +- Two writes per event (the entity row + the outbox row) — negligible cost. +- A cleanup policy is required: published rows accumulate. Each project decides retention (truncate after 30d, archive to cold storage, etc.) — out of scope for the template. \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/docs/architecture.md b/{{cookiecutter.project_slug}}/docs/architecture.md new file mode 100644 index 0000000..bc0a12d --- /dev/null +++ b/{{cookiecutter.project_slug}}/docs/architecture.md @@ -0,0 +1,184 @@ +# Architecture + +> Single reference document for the architecture of {{ cookiecutter.project_name }}. +> The full design rationale lives in [ADR 0001](adr/0001-clean-architecture.md). + +## Overview + +{{ cookiecutter.project_name }} is a {{ cookiecutter.project_description }} The codebase follows **Clean Architecture** (Uncle Bob, strict variant) with four concentric layers. Arrows show the **direction of allowed imports** — every arrow points **inward**. + +```mermaid +flowchart TB + subgraph Outer["Outer (delivery + I/O)"] + P["presentation
FastAPI routes • schemas
error handlers • middleware"] + I["infrastructure
SQLAlchemy • structlog • OTel
HTTP clients • DI container"] + end + subgraph Inner["Inner (business)"] + A["application
use cases • abstractions:
Clock • IdGenerator
Repository • EventBus • AuditLog"] + D["domain
entities • value objects
business exceptions
no third-party deps
no non-deterministic side effects"] + end + + P -->|Depends| A + A --> D + I --> A + I --> D + + classDef domain fill:#0e7490,stroke:#0e7490,color:#fff + classDef application fill:#1d4ed8,stroke:#1d4ed8,color:#fff + classDef infrastructure fill:#9a3412,stroke:#9a3412,color:#fff + classDef presentation fill:#7c3aed,stroke:#7c3aed,color:#fff + class D domain + class A application + class I infrastructure + class P presentation +``` + +Request flow for `GET /v1/users/me` — illustrates how an authenticated request crosses the layers: + +```mermaid +sequenceDiagram + participant C as HTTP Client + participant M as Middleware
(RequestId, AccessLog, ...) + participant Auth as get_current_user
(presentation, JWT verify) + participant R as users Router
(presentation) + participant DI as Depends
(presentation/api/dependencies) + participant U as EnsureUserExistsUseCase
(application) + participant Repo as SqlAlchemyUserRepository
(infrastructure) + participant DB as Postgres + + C->>M: GET /v1/users/me (Bearer ) + M->>Auth: enriched request + Auth-->>R: CurrentUser(subject, email, name) + R->>DI: get_or_provision_user(current_user) + DI->>U: execute(EnsureUserExistsInput(current_user)) + U->>Repo: find_by_subject(subject) + Repo->>DB: SELECT + DB-->>Repo: row | none + Repo-->>U: User | None + alt user missing + U->>Repo: add(User(subject, email, name)) + Repo->>DB: INSERT (transaction commits at request boundary) + end + U-->>R: EnsureUserExistsOutput(user) + R-->>M: UserReadSchema (200) + M-->>C: 200 + X-Request-ID +``` + +## Dependency rule + +A layer may import only from layers below it. Equivalently: + +| Layer | May import | +|---|---| +| `domain/` | Python stdlib only (no third party, no non-deterministic side effects) | +| `application/` | `domain/` | +| `infrastructure/` | `domain/`, `application/` | +| `presentation/` | `application/`, `domain/` (types only) — **never** `infrastructure/` directly | + +Concrete implementations are injected from `infrastructure/container.py` via FastAPI `Depends` wrappers in `presentation/api/dependencies/`. + +## Folder layout + +``` +src/{{ cookiecutter.package_name }}/ +├── domain/ +│ ├── entities/ # User, Order, ... (regular classes, identity equality) +│ ├── value_objects/ # Email, Money, UserId, ... (frozen dataclasses) +│ └── exceptions/ # DomainError + subclasses by bounded context +├── application/ +│ ├── use_cases/ # 1 file = 1 use case, ...UseCase suffix +│ ├── repositories/ # protocols (UserRepository, ...) +│ ├── clock.py # Clock protocol +│ ├── id_generator.py # IdGenerator protocol +│ ├── random_source.py # RandomSource protocol +│ ├── event_bus.py # EventBus protocol +│ ├── audit_log.py # AuditLog protocol +│ └── dtos/ # ...Input / ...Output frozen dataclasses +├── infrastructure/ +│ ├── persistence/ # SQLAlchemy ORM + concrete repositories +│ ├── observability/ # structlog + OTel setup, @traced decorator +│ ├── config/ # Pydantic Settings +│ ├── container.py # pure factory functions +│ ├── clock.py # SystemClock +│ ├── id_generator.py # Uuid4IdGenerator +│ └── random_source.py # SystemRandomSource +└── presentation/ + └── api/ + ├── app.py # FastAPI factory + ├── v1/ # routers per resource + ├── schemas/ # Pydantic ...Schema + ├── dependencies/ # Depends wrappers around the container + ├── middleware/ # SecurityHeaders, ... + └── error_handlers.py # DomainError → HTTP mapping +``` + +## Tests pyramid + +``` +tests/ +├── unit/ # Fakes only (no Mock). Mutation testing target. +│ ├── domain/ +│ ├── application/ +│ └── presentation/ +├── integration/ # testcontainers Postgres / Redis +│ └── infrastructure/ +├── e2e/ # FastAPI app booted, 1 happy + 1 critical-error per endpoint +│ └── api/ +└── fakes/ # FrozenClock, SequentialIdGenerator, InMemoryRepository, ... +``` + +See ADR [0002](adr/0002-tdd-and-mutation-testing.md) and [0003](adr/0003-fakes-only-in-unit-tests.md). + +## Multi-layer guardrails + +| Layer | Mechanism | What it enforces | +|---|---|---| +| Claude Code | `CLAUDE.md` + skills + hooks (settings.json) | Architecture, TDD, naming, no-mock, mapping HTTP, etc. | +| Pre-commit | `.pre-commit-config.yaml` | ruff, format, gitleaks, custom lints, commitizen | +| Git hooks | `commit-msg` + `pre-push` | Conventional Commits, pytest unit | +| CI | GitHub Actions | Everything: ruff, ty, pytest 3 levels, mutmut ≥90%, bandit, pip-audit, helm lint, kubeconform, polaris | + +A new convention is enforced at **at least two layers**, so that no single bypass can introduce drift. + +## Production posture + +### HTTP middleware stack + +Outermost to innermost: `RequestId` → `AccessLog` → `BodySizeLimit` → `RequestTimeout` → `CORS` → `SecurityHeaders` → handler. + +* `RequestId` accepts an upstream `X-Request-ID` or generates a UUID4 hex; binds it to the structlog context so every log line in the request scope carries it; echoes it back on the response. +* `AccessLog` emits one structured `http.request` event per request with method, path, status, duration_ms, client. `/health/*` paths are silenced. +* `BodySizeLimit` rejects requests whose `Content-Length` exceeds `request_max_body_bytes` (default 1 MiB) with a 413. +* `RequestTimeout` caps each request at `request_timeout_seconds` (default 30s) via `asyncio.wait_for`; on overrun it returns 504 and the handler coroutine is cancelled. + +### Prometheus metrics + +`prometheus-fastapi-instrumentator` exposes `/metrics` (always on, excluded from the OpenAPI schema). Out of the box: HTTP request count + duration histogram + in-progress gauge per `(method, handler, status)`, plus `prometheus_client` process metrics (CPU, memory, fds, uptime). `/metrics` itself and `/health/*` are excluded from the instrumentation to avoid feedback noise. + +The Helm chart ships a `ServiceMonitor` (gated by `metrics.enabled`) pointing at `/metrics` so a Prometheus operator picks it up automatically. + +### Rate limiting + +Intentionally **not** implemented in the application. Rate limiting belongs at the ingress / CDN layer (NGINX, Istio, Cloudflare, API Gateway) where it sees real client IPs and works correctly across multiple replicas. Application-level libraries like `slowapi` rely on per-pod in-memory counters by default — trivial to bypass in any horizontally-scaled deployment, and they require coupling to a shared store (Redis) for correctness, which is heavier than letting the ingress handle it. + +For business-rule throttling that genuinely depends on domain state (e.g., "5 password resets per hour per email"), implement the check inside the relevant use case — that is a domain concern, not an HTTP middleware concern. + +### Graceful shutdown + +Aligned across two layers: + +* uvicorn ships with `--timeout-graceful-shutdown 30` (Dockerfile `CMD`) — drains in-flight requests for up to 30s after SIGTERM. +* Helm `terminationGracePeriodSeconds: 35` is strictly greater than 30 so K8s never SIGKILLs before the drain completes. + +### Image supply chain (CI) + +* `ci-image-scan.yml` (PR): builds the image, runs Trivy (fails on HIGH/CRITICAL with a known fix), produces a CycloneDX SBOM artefact. +* `release.yml` (tag): rebuilds, scans, pushes, signs with `cosign` (keyless via Sigstore + GitHub OIDC), attaches the SBOM as a Sigstore attestation, attaches the SBOM JSON to the GitHub Release. + +Verify a release locally: + +```bash +cosign verify ghcr.io//:vX.Y.Z \ + --certificate-identity-regexp '^https://github.com///.github/workflows/release\.yml@.*' \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' +``` diff --git a/{{cookiecutter.project_slug}}/docs/runbooks/backups-and-restore.md b/{{cookiecutter.project_slug}}/docs/runbooks/backups-and-restore.md new file mode 100644 index 0000000..f77edf2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/docs/runbooks/backups-and-restore.md @@ -0,0 +1,164 @@ +# Backups & restore + +Operational reference for the persistent state of {{ cookiecutter.project_name }}. Read this **before** an incident — at 3 AM the bandwidth for learning is low. + +## Scope + +| What | Persisted ? | Backed up by this runbook ? | +|---|---|---| +| Postgres data (users, outbox_events, idempotency_records, …) | yes | **yes** | +| Application code | yes (in git) | covered by git | +| Container images | yes (in registry) | covered by registry retention | +| Helm values + secrets | yes (in the cluster) | depends on your secrets manager (SealedSecrets, ExternalSecrets) | +| OTel traces + Prometheus metrics | observability stack | not in scope — ephemeral by design | +| Structured logs | log aggregator (Loki / Cloud Logging) | not in scope — log aggregator's retention | + +The only thing this service owns and that **cannot be reconstructed** is the Postgres data. Everything else either lives in git, in a registry, or is observability data that is OK to lose. + +## Strategy: prefer cloud-managed Postgres + +Cloud-managed Postgres ships with continuous point-in-time recovery (PITR) and a tested restore path. Reinventing this on Kubernetes with pg_dump + a bucket is rarely worth it. The shipped Helm chart works with both. + +### AWS RDS for PostgreSQL + +* **Automated backups**: enable `BackupRetentionPeriod >= 7` on the DB instance. +* **PITR window**: 1 second granularity within the retention period. +* **Restore command**: + ```bash + aws rds restore-db-instance-to-point-in-time \ + --source-db-instance-identifier {{ cookiecutter.project_slug }}-prod \ + --target-db-instance-identifier {{ cookiecutter.project_slug }}-restore-$(date +%Y%m%d-%H%M) \ + --restore-time 2026-05-12T13:00:00Z + ``` +* **Cost note**: snapshots + transaction logs typically add 10-20% to the DB cost. + +### GCP Cloud SQL for PostgreSQL + +* **Automated backups**: enable in the instance settings; default retention 7 days, configurable up to 365. +* **PITR**: enable `binaryLogEnabled` (transaction logs). +* **Restore command**: + ```bash + gcloud sql backups restore \ + --restore-instance={{ cookiecutter.project_slug }}-restore-$(date +%Y%m%d-%H%M) \ + --backup-instance={{ cookiecutter.project_slug }}-prod + ``` +* **PITR clone** (preferred for accidental data loss): + ```bash + gcloud sql instances clone {{ cookiecutter.project_slug }}-prod \ + {{ cookiecutter.project_slug }}-restore-$(date +%Y%m%d-%H%M) \ + --point-in-time=2026-05-12T13:00:00Z + ``` + +### Azure Database for PostgreSQL — Flexible Server + +* **Automated backups**: enabled by default. Retention 7-35 days. +* **PITR window**: same as AWS, 1 second granularity within retention. +* **Restore command**: + ```bash + az postgres flexible-server restore \ + --resource-group {{ cookiecutter.project_slug }} \ + --name {{ cookiecutter.project_slug }}-restore-$(date +%Y%m%d-%H%M) \ + --source-server {{ cookiecutter.project_slug }}-prod \ + --restore-time 2026-05-12T13:00:00Z + ``` + +## Strategy: self-managed Postgres (only when cloud is not an option) + +If you must run Postgres in your own cluster (regulatory constraint, air-gapped env, …), the recommended setup is **pgBackRest** or **WAL-G** with continuous WAL archiving to object storage (S3 / GCS / Azure Blob / MinIO). + +This template does **not** ship a `pg_dump` CronJob: a daily logical dump trades 24 hours of RPO and a multi-hour RTO against ~2 minutes of operator cleverness. Use pgBackRest or move to cloud-managed before going to prod. + +If you absolutely must use `pg_dump` for an interim setup, the manual recipe is: + +```bash +# Daily, from a cluster-internal pod or operator host: +kubectl exec -n {{ cookiecutter.project_slug }} postgres-0 -- \ + pg_dump -U app -d {{ cookiecutter.package_name }} -Fc \ + > {{ cookiecutter.package_name }}-$(date +%Y%m%d).dump + +# Upload to your object store; lifecycle policy on the bucket +# handles retention. +aws s3 cp {{ cookiecutter.package_name }}-*.dump s3://my-backups/db/ +``` + +## Retention policy (recommended baseline) + +| Tier | Frequency | Retention | +|---|---|---| +| Continuous WAL / transaction log | continuous | 7 days | +| Daily full | daily | 7 days | +| Weekly full | weekly | 4 weeks | +| Monthly full | monthly | 12 months | + +Tune to your industry's regulatory needs (HIPAA, GDPR right-to-erasure, etc.). + +## Restore procedures + +### Full restore from PITR (cloud-managed) + +Use case: accidental `DELETE FROM users` or schema corruption. + +1. **Stop writes immediately**: scale the API deployment to 0 to prevent more damage. + ```bash + kubectl scale deployment/{{ cookiecutter.project_slug }} --replicas=0 + ``` +2. **Identify the restore point**: typically just before the bad transaction. The application logs should give you a UTC timestamp. +3. **Create the restore DB instance** (per-cloud command above). Use a distinct name (e.g. `-restore-YYYYMMDD-HHMM`). +4. **Verify the data**: connect to the restored DB and check the affected table(s) match expected state. + ```bash + psql $RESTORED_DB_URL -c "SELECT COUNT(*) FROM users WHERE created_at < '2026-05-12T13:00:00Z'" + ``` +5. **Swap DB URLs**: update the Helm release's `DB_URL` secret to point at the restored instance. + ```bash + helm upgrade {{ cookiecutter.project_slug }} ./helm/{{ cookiecutter.project_slug }} \ + --reuse-values --set secrets.dbUrl=$RESTORED_DB_URL + ``` +6. **Scale the API back up**: + ```bash + kubectl scale deployment/{{ cookiecutter.project_slug }} --replicas=2 + ``` +7. **Decommission the broken DB** only after monitoring shows the restore was successful (give it 24 hours). + +### Partial restore (one table, one row) + +Use case: accidental `UPDATE` on a few rows; the rest of the DB is fine. + +1. Restore to a side instance using PITR (steps 1-4 above). +2. Pull the affected rows out via `pg_dump --data-only --table=...`. +3. Apply to the production DB inside a transaction. +4. Drop the side instance. + +This is faster than a full swap but riskier — keep a SQL transcript of what you did. + +### Restore from `pg_dump` (self-managed fallback) + +```bash +# 1. Provision a fresh DB. +# 2. Apply migrations to bootstrap the schema: +DB_URL=$NEW_DB_URL uv run alembic upgrade head +# 3. Load the dump: +pg_restore -U app -d {{ cookiecutter.package_name }} --no-owner -j 4 {{ cookiecutter.package_name }}-20260512.dump +# 4. Sanity check: +psql $NEW_DB_URL -c "SELECT COUNT(*) FROM users" +``` + +## Backup verification (mandatory quarterly) + +A backup that was never restored is a hope. Every quarter, an engineer must: + +1. Provision a throw-away DB instance. +2. Restore the latest backup using the appropriate procedure above. +3. Run `uv run alembic current` against the restored DB — must show the latest revision. +4. Run `uv run pytest -m integration` against the restored DB (the integration tests do not destroy data, they roll back). +5. Add a line to `docs/runbooks/verification-log.md` with the date, operator, and any anomalies. +6. Decommission the throw-away DB. + +A team that has never run a restore drill **does not have backups**. + +## Things to NOT do + +* **Run pg_dump in production peak hours**: it acquires read locks; on a busy DB this blocks writes. Prefer cloud-managed PITR which uses replication, not dumps. +* **Use `pg_dump --clean`** on restore unless you really mean to drop the public schema first. Surprising number of incidents start here. +* **Forget the WAL retention**: PITR depends on transaction logs. If the WAL bucket has a 1-day lifecycle, your PITR window is 1 day, regardless of what you set on full backups. +* **Restore over the live DB**: always restore to a SIDE instance, verify, then swap. Never `psql $PROD < backup.sql`. +* **Skip step 7 (decommission)**: leftover restore instances are an attack surface (often less hardened, often forgotten in secrets rotation). diff --git a/{{cookiecutter.project_slug}}/docs/runbooks/disaster-recovery.md b/{{cookiecutter.project_slug}}/docs/runbooks/disaster-recovery.md new file mode 100644 index 0000000..7ca5a41 --- /dev/null +++ b/{{cookiecutter.project_slug}}/docs/runbooks/disaster-recovery.md @@ -0,0 +1,267 @@ +# Disaster recovery playbooks + +One playbook per failure mode. Each follows the same structure so you can scan fast: + +* **Signal** — what tells you this is happening (alert name, log pattern, user report) +* **Triage** (~5 min) — immediate diagnosis +* **Mitigation** (~15 min) — stop the bleeding +* **Resolution** — full fix +* **Postmortem** — what to document afterward + +Cross-reference: `docs/runbooks/backups-and-restore.md` for anything that needs DB restoration. + +--- + +## API has zero ready replicas + +**Signal**: `ApiNoReplicasReady` alert; users see connection refused / timeouts; `kubectl get pods` shows 0/N Ready. + +**Triage**: +```bash +kubectl get pods -n {{ cookiecutter.project_slug }} -l app.kubernetes.io/component=api +kubectl describe pod # ImagePullBackOff ? CrashLoopBackOff ? +kubectl logs --tail=100 +kubectl logs --previous --tail=100 # if it just crashed +``` + +Common root causes, in order of frequency: + +1. **ImagePullBackOff** — wrong tag, registry creds rotated. Fix: revert to the previous tag, redeploy. +2. **CrashLoopBackOff** — startup error: bad config, missing secret, DB unreachable, schema mismatch. Logs tell you which. +3. **Readiness probe failing** — app booted but `/health/ready` reports DB unreachable. Jump to "API cannot reach DB" playbook below. +4. **Node pressure** — pods pending because no node has capacity. `kubectl describe pod` mentions `FailedScheduling`. + +**Mitigation**: +- If the previous image was healthy: `helm rollback {{ cookiecutter.project_slug }} ` (find with `helm history`). +- If it's a config issue: revert the values overlay and `helm upgrade`. + +**Resolution**: fix the root cause, ship a corrected release. + +**Postmortem**: if the bad image passed `ci-quality`, the gap was in the integration tests or the migration drift check — add the missing test. + +--- + +## API cannot reach DB + +**Signal**: `/health/ready` returns 503; logs show `OperationalError: could not connect to server`; `ApiHigh5xxRate` alert. + +**Triage**: +```bash +# Is the DB pod up? +kubectl get pods -l app=postgres +# Or cloud-managed: check the cloud console / `aws rds describe-db-instances ...` + +# Can the API resolve the DB host from inside the pod? +kubectl exec deploy/{{ cookiecutter.project_slug }} -- nc -vz $DB_HOST 5432 + +# Did the credentials change? +kubectl get secret {{ cookiecutter.project_slug }}-secrets -o jsonpath='{.data.DB_URL}' | base64 -d +``` + +Common root causes: + +1. **DB connection pool exhausted** — too many requests, pool maxed out. Symptom: intermittent timeouts. Fix: scale up `DB_POOL_SIZE`, redeploy. +2. **NetworkPolicy regression** — a recent change blocked the namespace's egress to the DB. `kubectl get networkpolicies`. +3. **DB instance restarted** (cloud maintenance window). Usually self-heals in 1-2 min. +4. **DNS issue** — cluster CoreDNS hiccup. `kubectl exec` and `nslookup`. + +**Mitigation**: +- Restart the API pods so they re-open the pool: `kubectl rollout restart deployment/{{ cookiecutter.project_slug }}`. +- If DB is truly down and ETA is long: post a status banner; consider a maintenance page via the Ingress. + +**Resolution**: depends on the root cause; the cloud DB console gives the events history. + +--- + +## Migration failed mid-deploy + +**Signal**: `helm upgrade` job hook fails; pods stuck in `Init`; logs from the migration job show an Alembic error. + +**Triage**: +```bash +kubectl logs job/{{ cookiecutter.project_slug }}-migrations +kubectl exec deploy/{{ cookiecutter.project_slug }} -- uv run alembic current +``` + +**Mitigation** (decide based on what got applied): + +* **Migration didn't start** (e.g. lock conflict): re-run the deploy after fixing the conflict. +* **Migration partially applied**: roll back the schema using the matching downgrade. + ```bash + # From a pod with the new image (the previous image may not know the new downgrade): + kubectl run -it --rm migrate-fix --image=$NEW_IMAGE -- \ + sh -c 'DB_URL=$DB_URL uv run alembic downgrade -1' + ``` +* **Migration applied but app crashes on the new schema** (rare): `helm rollback` to the previous release; the schema is one step ahead. Either ship a no-op migration to bring the model in line, or write a corrective migration. + +**Resolution**: fix the migration, run the upgrade/downgrade/upgrade roundtrip locally (the `database-and-migrations` skill describes this), ship the fixed migration. + +**Postmortem**: a failed migration on prod usually means the migration was not tested against prod-shaped data. Update the integration tests with a fixture that mirrors prod-volume + prod-distribution. + +--- + +## Outbox backlog growing / lag too high + +**Signal**: `OutboxBacklog` or `OutboxLag` alert; `outbox_oldest_pending_age_seconds` gauge in Grafana. + +**Triage**: +```bash +# Is the relay alive? +kubectl get pods -l app.kubernetes.io/component=relay +kubectl logs deploy/{{ cookiecutter.project_slug }}-relay --tail=200 + +# What is the backlog shape? +psql $DB_URL <<'SQL' +SELECT status, COUNT(*), MIN(created_at) AS oldest +FROM outbox_events +GROUP BY status; +SQL +``` + +Common root causes: + +1. **Relay is not running** (zero pods, crashloop) — fix the relay first; consume the backlog. +2. **All handlers are failing** on the head-of-line row — `last_error` column tells you the cause. Fix the handler / downstream, redeploy. +3. **Producer is outpacing the relay** — scale up `relay.replicaCount`. `SELECT FOR UPDATE SKIP LOCKED` makes this safe. +4. **Single poison row at the head** blocking the order — see "outbox poisoned rows" playbook below. + +**Mitigation**: +- Scale up the relay: `kubectl scale deploy/{{ cookiecutter.project_slug }}-relay --replicas=3`. +- If a poison row is blocking: manual replay or quarantine (see below). + +**Resolution**: address the handler regression or the downstream outage. + +--- + +## Outbox poisoned rows present + +**Signal**: `OutboxPoisoned` alert (critical). + +**Triage**: +```sql +SELECT id, event_name, last_error, attempts, last_attempt_at +FROM outbox_events +WHERE status = 'poisoned' +ORDER BY last_attempt_at DESC +LIMIT 10; +``` + +For each row, decide: + +* **Bad payload** (the producer wrote a malformed event): emit a compensating action manually, then delete the poison row. +* **Transient downstream outage** (now resolved): manual replay. + ```sql + UPDATE outbox_events + SET status='pending', attempts=0, next_attempt_at=NULL, last_error=NULL + WHERE id = ''; + ``` +* **Permanent incompatibility** (handler will always fail on this payload): archive to a side table and delete. + ```sql + CREATE TABLE IF NOT EXISTS outbox_poison_archive AS TABLE outbox_events WITH NO DATA; + INSERT INTO outbox_poison_archive SELECT * FROM outbox_events WHERE id = ''; + DELETE FROM outbox_events WHERE id = ''; + ``` + +**Resolution**: if the poison was caused by a handler regression, ship the fix AND replay the affected rows. + +See `building-a-feature` § "Transactions and events" for the lifecycle and compensating-event pattern (Saga). + +--- + +## Accidental data loss (DELETE / UPDATE on too many rows) + +**Signal**: a user / engineer reports rows disappeared; the audit log shows a `DELETE` they did not intend. + +**Triage** (fast): +1. **Stop writes**: `kubectl scale deploy/{{ cookiecutter.project_slug }} --replicas=0`. Damage in progress means each second adds rows to the loss. +2. Note the UTC timestamp just before the bad operation. The structured logs have it. + +**Mitigation**: +- Restore via PITR to a SIDE instance — see `backups-and-restore.md` § "Partial restore". +- Copy the affected rows back to production inside a transaction. +- Scale back up. + +**Resolution**: add a `DELETE` audit guard (an integration test that asserts no use case ever issues a `DELETE` without a `WHERE id = ...`), or move the affected entity behind a soft-delete column. + +**Postmortem**: 99% of these come from a missing `WHERE` in an ad-hoc SQL session. Add a `BEGIN; ... ROLLBACK; -- inspect first` policy in the team handbook. + +--- + +## Helm release stuck in failed upgrade + +**Signal**: `helm status {{ cookiecutter.project_slug }}` shows `FAILED`; pods are mixed (some new, some old) or all crashing. + +**Triage**: +```bash +helm history {{ cookiecutter.project_slug }} +helm get values {{ cookiecutter.project_slug }} --revision +``` + +**Mitigation**: +```bash +helm rollback {{ cookiecutter.project_slug }} +``` + +If `rollback` itself fails (rare): +```bash +# Force-clear the lock by deleting the stuck Helm secret (use with care). +kubectl get secrets -l owner=helm,name={{ cookiecutter.project_slug }} -o name | tail -1 | xargs kubectl delete +# Then redeploy from the last-good revision values: +helm upgrade --install {{ cookiecutter.project_slug }} ./helm/{{ cookiecutter.project_slug }} -f +``` + +**Resolution**: the rollback gets you back to a known state. Fix the failed upgrade in a feature branch, run `helm template + kubeconform + polaris` locally before re-trying. + +--- + +## Secrets compromised + +**Signal**: an alert from your secrets manager, a leaked-credentials notification from a partner, or a developer reporting they pushed a secret to a public repo. + +**Triage**: +- Identify which secrets are affected: `DB_URL`, `AUTH_JWT_SECRET`, broker credentials, … +- Determine the blast radius: was the secret used for read-only access or write? + +**Mitigation** (in this order): +1. **Rotate** the secret at the source (cloud console / IdP). +2. **Update** the Kubernetes secret: + ```bash + kubectl create secret generic {{ cookiecutter.project_slug }}-secrets \ + --from-literal=DB_URL= \ + --from-literal=AUTH_JWT_SECRET= \ + --dry-run=client -o yaml | kubectl apply -f - + ``` +3. **Restart** the API + relay pods to pick up the new secret: + ```bash + kubectl rollout restart deploy/{{ cookiecutter.project_slug }} + kubectl rollout restart deploy/{{ cookiecutter.project_slug }}-relay + ``` +4. **Revoke** any tokens issued under the compromised JWT secret (force re-login). +5. **Audit** the logs for the time window the secret was exposed: any suspicious activity? + +**Postmortem**: the compromise vector — was it a leaked .env, a CI log, a developer laptop ? Fix the leak channel, not just the secret. Document the incident in `docs/runbooks/verification-log.md`. + +--- + +## Region / AZ outage (multi-region setups only) + +**Signal**: the cloud provider's status page reports a regional outage; multiple unrelated alerts firing simultaneously. + +This template ships single-region by default. The playbook applies only if the project has wired multi-region. If it has: + +**Mitigation**: +- Switch the load balancer / global DNS to the secondary region. +- Ensure the secondary's Postgres replica is promoted to primary if the outage is sustained (>30 min). +- Communicate the status banner to clients. + +**Resolution**: cloud outages are out of your control. Document RPO/RTO actuals vs target. + +--- + +## What does NOT belong in this runbook + +* **Application bugs** — those go in the code, the tests, and the post-incident write-up. The runbook is for *operational* failures, not bad business logic. +* **Performance tuning** — see `onboarding-soma` § "Observability patterns" and the Grafana dashboards. +* **Cost incidents** — separate budget / FinOps runbook. + +When you add a new playbook here, keep the 5-section structure and link from the corresponding PrometheusRule alert annotation. diff --git a/{{cookiecutter.project_slug}}/docs/runbooks/verification-log.md b/{{cookiecutter.project_slug}}/docs/runbooks/verification-log.md new file mode 100644 index 0000000..b37964b --- /dev/null +++ b/{{cookiecutter.project_slug}}/docs/runbooks/verification-log.md @@ -0,0 +1,18 @@ +# Backup verification log + +Quarterly restore-drill record — see `backups-and-restore.md` § "Backup verification". + +| Date (UTC) | Operator | Restore source | Restore target | `alembic current` | `pytest -m integration` | Anomalies / time-to-restore | +|---|---|---|---|---|---|---| +| _example_ 2026-04-15 | alice@soma | prod backup 2026-04-14 23:59 | side instance `psql-restore-20260415` | `0001_init` | 16 passed | ~12 min total; no anomalies | + +## Process reminder + +1. Provision a throw-away DB instance. +2. Restore the latest backup per the cloud's procedure in `backups-and-restore.md`. +3. Run `DB_URL= uv run alembic current` — must report the latest revision. +4. Run `DB_URL= uv run pytest -m integration` — must be green (the integration tests roll back, so they don't pollute the restored data). +5. Append a row to the table above. +6. Decommission the throw-away DB. + +If the drill fails, that is a P1 incident — backups are not actually restorable until the gap is fixed. diff --git a/{{cookiecutter.project_slug}}/docs/swagger-oauth2.md b/{{cookiecutter.project_slug}}/docs/swagger-oauth2.md new file mode 100644 index 0000000..96c9ed8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/docs/swagger-oauth2.md @@ -0,0 +1,152 @@ +{% raw %}# Swagger UI OAuth2 / Authorization Code flow + +## TL;DR — local Keycloak (devcontainer) + +Projects generated with `swagger_auth_scheme=oauth2_auth_code` ship a local Keycloak inside the devcontainer (`.devcontainer/docker-compose.yml`, realm `dev` auto-imported from `.devcontainer/keycloak/realm-export.json`). `.env.example` is already wired to it. + +1. Open the devcontainer — Postgres, Keycloak and the API start automatically. +2. Run `just dev`, visit `http://localhost:8000/docs`. +3. Click **Authorize** → the browser redirects to `http://localhost:8080`. +4. Log in as **`alice` / `alice`** (regular user) or **`admin` / `admin`** (admin role). +5. Swagger UI receives the token and uses it on every subsequent call. + +Keycloak admin console is at `http://localhost:8080`, admin/admin. Tokens issued from the `dev` realm carry `aud=api`, which `AUTH_JWT_AUDIENCE=api` in `.env` validates. + +The rest of this document explains the underlying mechanism and how to point at Azure AD / Auth0 / a managed Keycloak in staging. + +--- + +## Switching schemes + +By default the API ships with the **HTTPBearer** Swagger UI scheme: +click "Authorize", paste a JWT, every subsequent request is +authenticated. Functional but every dev has to obtain a token elsewhere +first (curl + a refresh script, an internal portal, …). + +When the IdP supports OpenID Connect (Azure AD / Entra ID, Auth0, +Keycloak, Okta, AWS Cognito, …) you can switch to the **Authorization +Code + PKCE** flow: clicking "Authorize" in `/docs` redirects the +browser to the IdP, the dev logs in with their SSO, and Swagger UI +receives the token back automatically. + +The code already supports both schemes — flipping is a matter of +setting four env vars (see below) plus registering a dedicated app for +Swagger UI on your IdP. + +## How the switch works + +`presentation/api/dependencies/auth.py::_build_security_scheme` reads +the settings at startup: + +- `SWAGGER_OAUTH2_AUTHORIZATION_URL` and `SWAGGER_OAUTH2_TOKEN_URL` + both set → the API publishes the `OAuth2AuthorizationCodeBearer` + scheme in its OpenAPI document. +- `SWAGGER_OAUTH2_CLIENT_ID` set → `app.py` configures Swagger UI with + `swagger_ui_init_oauth` so the Authorize button knows which client to + identify as. + +Restart the API after changing the env vars. + +## env vars + +```env +SWAGGER_OAUTH2_AUTHORIZATION_URL= +SWAGGER_OAUTH2_TOKEN_URL= +SWAGGER_OAUTH2_CLIENT_ID= +SWAGGER_OAUTH2_SCOPES=openid,email,profile +SWAGGER_OAUTH2_PKCE_ENABLED=true +``` + +## App registration walkthrough — Azure AD / Microsoft Entra ID + +1. **Open Azure portal** → Azure Active Directory → *App registrations* → + *New registration*. +2. **Name**: `-swagger-ui` (this is a DIFFERENT app + from the one that backs your API audience — keep them separate so + Swagger UI can be a public client without compromising your API + secret). +3. **Supported account types**: pick the same audience as your API + (single tenant typically). +4. **Redirect URI**: + - Platform: **Single-page application**. + - URI: `https:///docs/oauth2-redirect`. For local dev + add `http://localhost:8000/docs/oauth2-redirect` as well. +5. **Register**. Note the *Application (client) ID* — this is your + `SWAGGER_OAUTH2_CLIENT_ID`. +6. **API permissions** → *Add a permission* → select your API app → + pick the scopes your API expects (typically `openid`, `email`, + `profile`, plus any custom scope you defined). Grant admin consent + if required by tenant policy. +7. **Manifest** → confirm `accessTokenAcceptedVersion = 2` (v2.0 + tokens carry `email` and `name` claims when the matching scopes are + granted). +8. **Authentication** → *Implicit grant and hybrid flows*: leave both + checkboxes UNCHECKED. SPA + PKCE replaces them. + +### Settings to put in your `.env` + +```env +AUTH_JWT_ALGORITHM=RS256 +AUTH_JWT_JWKS_URL=https://login.microsoftonline.com//discovery/v2.0/keys +AUTH_JWT_AUDIENCE= # NOT the Swagger UI app id +AUTH_JWT_ISSUER=https://login.microsoftonline.com//v2.0 + +SWAGGER_OAUTH2_AUTHORIZATION_URL=https://login.microsoftonline.com//oauth2/v2.0/authorize +SWAGGER_OAUTH2_TOKEN_URL=https://login.microsoftonline.com//oauth2/v2.0/token +SWAGGER_OAUTH2_CLIENT_ID= +SWAGGER_OAUTH2_SCOPES=openid,email,profile +SWAGGER_OAUTH2_PKCE_ENABLED=true +``` + +## App registration walkthrough — other IdPs (short notes) + +### Auth0 + +- *Applications* → *Create Application* → *Single Page Application*. +- Allowed Callback URLs: `https:///docs/oauth2-redirect`. +- `SWAGGER_OAUTH2_AUTHORIZATION_URL`: `https://.auth0.com/authorize`. +- `SWAGGER_OAUTH2_TOKEN_URL`: `https://.auth0.com/oauth/token`. +- The API itself is registered separately under *APIs* — its + *Identifier* is your `AUTH_JWT_AUDIENCE`. + +### Keycloak + +- Create a *Public* client (SPA). +- Valid Redirect URIs: `https:///docs/oauth2-redirect`. +- `SWAGGER_OAUTH2_AUTHORIZATION_URL`: + `https:///realms//protocol/openid-connect/auth`. +- `SWAGGER_OAUTH2_TOKEN_URL`: + `https:///realms//protocol/openid-connect/token`. +- Audience mapping is configured on the API client. + +### Okta + +- *Applications* → *Create App Integration* → *OIDC* + *Single-Page + Application*. +- Sign-in redirect URIs: `https:///docs/oauth2-redirect`. +- Authorize / Token URLs come from the org's authorization server. + +## Verifying the flow + +1. Start the API with the OAuth2 env vars set. +2. Open `https:///docs`. +3. Click **Authorize** — Swagger UI shows the OAuth2 scopes; tick the + ones you need and click **Authorize** again. +4. Browser redirects to the IdP login page, you authenticate, the IdP + redirects back to `/docs/oauth2-redirect`, Swagger UI captures the + token. +5. From now on, "Try it out" attaches the token to every request. + +## Troubleshooting + +- **Redirect URI mismatch** — the IdP rejects the callback. Verify the + URL registered on the IdP exactly matches + `/docs/oauth2-redirect` (including the scheme). +- **PKCE not supported** — Set `SWAGGER_OAUTH2_PKCE_ENABLED=false`. Use + only with non-public clients ; SPAs MUST keep PKCE. +- **Audience / issuer rejected** — verify the JWT inspector at the IdP + shows `aud = ` and `iss = `. +- **`AUTH_MISSING_PROFILE_CLAIMS`** on `/v1/users/me` — the IdP is not + emitting `email` / `name`. Grant the `email` and `profile` scopes on + the API permissions side of the Swagger UI app. +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/Chart.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/Chart.yaml new file mode 100644 index 0000000..78e5b00 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: {{ cookiecutter.project_slug }} +description: Helm chart for {{ cookiecutter.project_name }}. +type: application +version: 0.1.0 +appVersion: "0.1.0" +maintainers: + - name: {{ cookiecutter.author_name }} + email: {{ cookiecutter.author_email }} diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/_helpers.tpl b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/_helpers.tpl new file mode 100644 index 0000000..37b4b64 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/_helpers.tpl @@ -0,0 +1,47 @@ +{% raw %}{{/* +Common helpers for the chart. +*/}} + +{{- define "app.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "app.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "app.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "app.labels" -}} +helm.sh/chart: {{ include "app.chart" . }} +{{ include "app.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "app.selectorLabels" -}} +app.kubernetes.io/name: {{ include "app.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "app.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "app.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- default "default" .Values.serviceAccount.name -}} +{{- end -}} +{{- end -}} +{% endraw %} diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/configmap.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/configmap.yaml new file mode 100644 index 0000000..5f05921 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/configmap.yaml @@ -0,0 +1,33 @@ +{% raw %}apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "app.fullname" . }}-config + labels: + {{- include "app.labels" . | nindent 4 }} +data: + APP_NAME: {{ .Release.Name | quote }} + LOG_FORMAT: {{ .Values.config.logFormat | quote }} + LOG_LEVEL: {{ .Values.config.logLevel | quote }} + SERVICE_NAME: {{ .Values.config.serviceName | quote }} + OTEL_EXPORTER_OTLP_ENDPOINT: {{ .Values.config.otelExporterOtlpEndpoint | quote }} + OTEL_TRACES_SAMPLER_ARG: {{ .Values.config.otelTracesSamplerArg | quote }} + CORS_ALLOWED_ORIGINS: {{ .Values.config.corsAllowedOrigins | quote }} + DB_POOL_SIZE: {{ .Values.config.dbPoolSize | quote }} + DB_POOL_MAX_OVERFLOW: {{ .Values.config.dbPoolMaxOverflow | quote }} + DB_POOL_TIMEOUT: {{ .Values.config.dbPoolTimeout | quote }} + REQUEST_MAX_BODY_BYTES: {{ .Values.config.requestMaxBodyBytes | quote }} + REQUEST_TIMEOUT_SECONDS: {{ .Values.config.requestTimeoutSeconds | quote }} + IDEMPOTENCY_ENABLED: {{ .Values.config.idempotencyEnabled | quote }} + IDEMPOTENCY_METHODS: {{ .Values.config.idempotencyMethods | quote }} + IDEMPOTENCY_TTL_SECONDS: {{ .Values.config.idempotencyTtlSeconds | quote }} + AUTH_JWT_ALGORITHM: {{ .Values.config.authJwtAlgorithm | quote }} + AUTH_JWT_JWKS_URL: {{ .Values.config.authJwtJwksUrl | quote }} + AUTH_JWT_AUDIENCE: {{ .Values.config.authJwtAudience | quote }} + AUTH_JWT_ISSUER: {{ .Values.config.authJwtIssuer | quote }} + AUTH_JWT_ROLES_CLAIM: {{ .Values.config.authJwtRolesClaim | quote }} + SWAGGER_OAUTH2_AUTHORIZATION_URL: {{ .Values.config.swaggerOauth2AuthorizationUrl | quote }} + SWAGGER_OAUTH2_TOKEN_URL: {{ .Values.config.swaggerOauth2TokenUrl | quote }} + SWAGGER_OAUTH2_CLIENT_ID: {{ .Values.config.swaggerOauth2ClientId | quote }} + SWAGGER_OAUTH2_SCOPES: {{ .Values.config.swaggerOauth2Scopes | quote }} + SWAGGER_OAUTH2_PKCE_ENABLED: {{ .Values.config.swaggerOauth2PkceEnabled | quote }} +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/deployment.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/deployment.yaml new file mode 100644 index 0000000..80eded2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/deployment.yaml @@ -0,0 +1,83 @@ +{% raw %}apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "app.fullname" . }} + labels: + {{- include "app.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "app.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "app.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "app.serviceAccountName" . }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "app.fullname" . }}-config + {{- if .Values.secrets.dbUrl }} + - secretRef: + name: {{ include "app.fullname" . }}-secrets + {{- end }} + livenessProbe: + httpGet: + path: {{ .Values.probes.liveness.path }} + port: http + initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + readinessProbe: + httpGet: + path: {{ .Values.probes.readiness.path }} + port: http + initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + startupProbe: + httpGet: + path: {{ .Values.probes.startup.path }} + port: http + failureThreshold: {{ .Values.probes.startup.failureThreshold }} + periodSeconds: {{ .Values.probes.startup.periodSeconds }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/hpa.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/hpa.yaml new file mode 100644 index 0000000..4e22a43 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/hpa.yaml @@ -0,0 +1,23 @@ +{% raw %}{{- if .Values.autoscaling.enabled -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "app.fullname" . }} + labels: + {{- include "app.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "app.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/ingress.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/ingress.yaml new file mode 100644 index 0000000..f0810ab --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/ingress.yaml @@ -0,0 +1,42 @@ +{% raw %}{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "app.fullname" . }} + labels: + {{- include "app.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "app.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/migration-job.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/migration-job.yaml new file mode 100644 index 0000000..77e90ed --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/migration-job.yaml @@ -0,0 +1,41 @@ +{% raw %}{{- if .Values.migrations.enabled -}} +# Pre-install / pre-upgrade hook running `alembic upgrade head`. Ensures the +# DB schema is at the right version before any pod boots on the new image. +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "app.fullname" . }}-migrations + labels: + {{- include "app.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + backoffLimit: 1 + template: + metadata: + labels: + {{- include "app.selectorLabels" . | nindent 8 }} + component: migrations + spec: + restartPolicy: Never + serviceAccountName: {{ include "app.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: alembic + image: "{{ .Values.migrations.image.repository | default .Values.image.repository }}:{{ .Values.migrations.image.tag | default (.Values.image.tag | default .Chart.AppVersion) }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["alembic", "upgrade", "head"] + envFrom: + - configMapRef: + name: {{ include "app.fullname" . }}-config + {{- if .Values.secrets.dbUrl }} + - secretRef: + name: {{ include "app.fullname" . }}-secrets + {{- end }} + resources: + {{- toYaml .Values.migrations.resources | nindent 12 }} +{{- end }} +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/pdb.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/pdb.yaml new file mode 100644 index 0000000..10be619 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/pdb.yaml @@ -0,0 +1,14 @@ +{% raw %}{{- if .Values.podDisruptionBudget.enabled -}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "app.fullname" . }} + labels: + {{- include "app.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "app.selectorLabels" . | nindent 6 }} +{{- end }} +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/prometheusrule.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/prometheusrule.yaml new file mode 100644 index 0000000..146d6e6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/prometheusrule.yaml @@ -0,0 +1,154 @@ +{% raw %}{{- if and .Values.metrics.enabled .Values.metrics.alerts.enabled -}} +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: {{ include "app.fullname" . }}-alerts + labels: + {{- include "app.labels" . | nindent 4 }} + {{- with .Values.metrics.alerts.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + groups: + # --- Outbox relay ----------------------------------------------- + # Read the ``building-a-feature`` § "Transactions and events" for the runbook backing + # each alert: manual replay, scaling, dead-letter inspection. + - name: {{ include "app.name" . }}.outbox + rules: + - alert: OutboxBacklog + expr: outbox_events_pending > {{ .Values.metrics.alerts.thresholds.outboxBacklog }} + for: 5m + labels: + severity: warning + service: {{ include "app.name" . }} + component: relay + {{- with .Values.metrics.alerts.labels }} + {{- toYaml . | nindent 12 }} + {{- end }} + annotations: + summary: "Outbox backlog above {{ .Values.metrics.alerts.thresholds.outboxBacklog }}" + description: "More than {{ .Values.metrics.alerts.thresholds.outboxBacklog }} rows are sitting in outbox_events with status='pending' for 5+ minutes. Producer outpacing the relay, or relay stuck. Inspect logs of the {{ include "app.fullname" . }}-relay Deployment." + runbook_url: "docs/runbooks/disaster-recovery.md#outbox-backlog-growing--lag-too-high" + + - alert: OutboxLag + expr: outbox_oldest_pending_age_seconds > {{ .Values.metrics.alerts.thresholds.outboxLagSeconds }} + for: 2m + labels: + severity: warning + service: {{ include "app.name" . }} + component: relay + {{- with .Values.metrics.alerts.labels }} + {{- toYaml . | nindent 12 }} + {{- end }} + annotations: + summary: "Outbox oldest pending row older than {{ .Values.metrics.alerts.thresholds.outboxLagSeconds }}s" + description: "The oldest pending outbox row has been waiting > {{ .Values.metrics.alerts.thresholds.outboxLagSeconds }} seconds. Either the relay is not running, or all handlers are failing on the head-of-line row. Check the relay Deployment status and the last_error column on outbox_events." + runbook_url: "docs/runbooks/disaster-recovery.md#outbox-backlog-growing--lag-too-high" + + - alert: OutboxPoisoned + expr: outbox_events_poisoned > 0 + for: 1m + labels: + severity: critical + service: {{ include "app.name" . }} + component: relay + {{- with .Values.metrics.alerts.labels }} + {{- toYaml . | nindent 12 }} + {{- end }} + annotations: + summary: "Poisoned outbox rows present - manual triage required" + description: "One or more rows have exhausted their retries (MAX_ATTEMPTS=10). Inspect them with SELECT id, event_name, last_error FROM outbox_events WHERE status='poisoned'. Manual replay procedure in the building-a-feature § 'Transactions and events' section." + runbook_url: "docs/runbooks/disaster-recovery.md#outbox-poisoned-rows-present" + + - alert: OutboxHandlerErrorRate + expr: sum(rate(outbox_handler_failures_total[5m])) > {{ .Values.metrics.alerts.thresholds.outboxHandlerErrorRate }} + for: 5m + labels: + severity: warning + service: {{ include "app.name" . }} + component: relay + {{- with .Values.metrics.alerts.labels }} + {{- toYaml . | nindent 12 }} + {{- end }} + annotations: + summary: "Outbox handler failure rate above {{ .Values.metrics.alerts.thresholds.outboxHandlerErrorRate }}/s" + description: "Sustained handler failures on the relay. Likely a handler regression or a downstream outage. Group by event_name in Prometheus to pinpoint which handler is failing." + runbook_url: "docs/runbooks/disaster-recovery.md#outbox-backlog-growing--lag-too-high" + + # --- API -------------------------------------------------------- + - name: {{ include "app.name" . }}.api + rules: + - alert: ApiHigh5xxRate + expr: | + sum(rate(http_requests_total{status=~"5..",service="{{ include "app.fullname" . }}"}[5m])) + / + sum(rate(http_requests_total{service="{{ include "app.fullname" . }}"}[5m])) + > {{ .Values.metrics.alerts.thresholds.api5xxRate }} + for: 5m + labels: + severity: critical + service: {{ include "app.name" . }} + component: api + {{- with .Values.metrics.alerts.labels }} + {{- toYaml . | nindent 12 }} + {{- end }} + annotations: + summary: "HTTP 5xx rate above {{ .Values.metrics.alerts.thresholds.api5xxRate }}" + description: "More than {{ .Values.metrics.alerts.thresholds.api5xxRate }} of requests are returning 5xx over the last 5 minutes. Check API pod logs and recent deploys." + runbook_url: "docs/runbooks/disaster-recovery.md#api-cannot-reach-db" + + - alert: ApiHighP99Latency + expr: | + histogram_quantile( + 0.99, + sum by (le) (rate(http_request_duration_seconds_bucket{service="{{ include "app.fullname" . }}"}[5m])) + ) > {{ .Values.metrics.alerts.thresholds.apiP99LatencySeconds }} + for: 10m + labels: + severity: warning + service: {{ include "app.name" . }} + component: api + {{- with .Values.metrics.alerts.labels }} + {{- toYaml . | nindent 12 }} + {{- end }} + annotations: + summary: "API p99 latency above {{ .Values.metrics.alerts.thresholds.apiP99LatencySeconds }}s" + description: "The p99 request latency has been above {{ .Values.metrics.alerts.thresholds.apiP99LatencySeconds }}s for 10 minutes. Investigate slow endpoints with the per-handler buckets and recent DB query plans." + runbook_url: "docs/runbooks/disaster-recovery.md#api-cannot-reach-db" + + - alert: ApiNoReplicasReady + expr: kube_deployment_status_replicas_available{namespace="{{ .Release.Namespace }}",deployment="{{ include "app.fullname" . }}"} == 0 + for: 2m + labels: + severity: critical + service: {{ include "app.name" . }} + component: api + {{- with .Values.metrics.alerts.labels }} + {{- toYaml . | nindent 12 }} + {{- end }} + annotations: + summary: "API has zero ready replicas" + description: "Deployment {{ include "app.fullname" . }} in namespace {{ .Release.Namespace }} has no ready replica for 2 minutes. The service is unavailable. Check pod events and image pull / probe failures." + runbook_url: "docs/runbooks/disaster-recovery.md#api-has-zero-ready-replicas" + + {{- if .Values.relay.enabled }} + # --- Relay availability ----------------------------------------- + - name: {{ include "app.name" . }}.relay + rules: + - alert: RelayNoReplicasReady + expr: kube_deployment_status_replicas_available{namespace="{{ .Release.Namespace }}",deployment="{{ include "app.fullname" . }}-relay"} == 0 + for: 2m + labels: + severity: critical + service: {{ include "app.name" . }} + component: relay + {{- with .Values.metrics.alerts.labels }} + {{- toYaml . | nindent 12 }} + {{- end }} + annotations: + summary: "Outbox relay has zero ready replicas" + description: "Deployment {{ include "app.fullname" . }}-relay in namespace {{ .Release.Namespace }} has no ready replica for 2 minutes. Events will pile up in outbox_events. Inspect pod events." + runbook_url: "docs/runbooks/disaster-recovery.md#outbox-backlog-growing--lag-too-high" + {{- end }} +{{- end }} +{% endraw %} diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/relay-deployment.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/relay-deployment.yaml new file mode 100644 index 0000000..b4e9f0e --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/relay-deployment.yaml @@ -0,0 +1,80 @@ +{% raw %}{{- if .Values.relay.enabled -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "app.fullname" . }}-relay + labels: + {{- include "app.labels" . | nindent 4 }} + app.kubernetes.io/component: relay +spec: + replicas: {{ .Values.relay.replicaCount }} + selector: + matchLabels: + {{- include "app.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: relay + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "app.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: relay + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "app.serviceAccountName" . }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: relay + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + command: + - python + - -m + - {{ .Values.config.serviceName }}.infrastructure.jobs.outbox_relay + ports: + - name: metrics + containerPort: {{ .Values.relay.metricsPort }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "app.fullname" . }}-config + {{- if or .Values.secrets.dbUrl .Values.secrets.authJwtSecret }} + - secretRef: + name: {{ include "app.fullname" . }}-secrets + {{- end }} + # The relay is a polling worker — a TCP probe on the metrics + # port is the cheapest "is the process alive" signal. Skip + # readiness probes (no traffic routed to it). + livenessProbe: + tcpSocket: + port: metrics + initialDelaySeconds: 10 + periodSeconds: 30 + resources: + {{- toYaml .Values.relay.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} +{% endraw %} diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/relay-service.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/relay-service.yaml new file mode 100644 index 0000000..9ef5a4e --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/relay-service.yaml @@ -0,0 +1,22 @@ +{% raw %}{{- if .Values.relay.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "app.fullname" . }}-relay + labels: + {{- include "app.labels" . | nindent 4 }} + app.kubernetes.io/component: relay +spec: + # ClusterIP only — the relay exposes /metrics for Prometheus scrape; + # never expose it through an Ingress. + type: ClusterIP + ports: + - port: {{ .Values.relay.metricsPort }} + targetPort: metrics + protocol: TCP + name: metrics + selector: + {{- include "app.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: relay +{{- end }} +{% endraw %} diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/relay-servicemonitor.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/relay-servicemonitor.yaml new file mode 100644 index 0000000..33e0fe0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/relay-servicemonitor.yaml @@ -0,0 +1,19 @@ +{% raw %}{{- if and .Values.relay.enabled .Values.metrics.enabled -}} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "app.fullname" . }}-relay + labels: + {{- include "app.labels" . | nindent 4 }} + app.kubernetes.io/component: relay +spec: + selector: + matchLabels: + {{- include "app.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: relay + endpoints: + - port: metrics + path: {{ .Values.metrics.path }} + interval: {{ .Values.metrics.interval }} +{{- end }} +{% endraw %} diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/secret.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/secret.yaml new file mode 100644 index 0000000..e33ea95 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/secret.yaml @@ -0,0 +1,17 @@ +{% raw %}{{- if or .Values.secrets.dbUrl .Values.secrets.authJwtSecret -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "app.fullname" . }}-secrets + labels: + {{- include "app.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- if .Values.secrets.dbUrl }} + DB_URL: {{ .Values.secrets.dbUrl | quote }} + {{- end }} + {{- if .Values.secrets.authJwtSecret }} + AUTH_JWT_SECRET: {{ .Values.secrets.authJwtSecret | quote }} + {{- end }} +{{- end }} +{% endraw %} diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/service.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/service.yaml new file mode 100644 index 0000000..a1f69fd --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/service.yaml @@ -0,0 +1,16 @@ +{% raw %}apiVersion: v1 +kind: Service +metadata: + name: {{ include "app.fullname" . }} + labels: + {{- include "app.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "app.selectorLabels" . | nindent 4 }} +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/serviceaccount.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/serviceaccount.yaml new file mode 100644 index 0000000..5c77ec2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{% raw %}{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "app.serviceAccountName" . }} + labels: + {{- include "app.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/servicemonitor.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/servicemonitor.yaml new file mode 100644 index 0000000..0a3ae1d --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/servicemonitor.yaml @@ -0,0 +1,17 @@ +{% raw %}{{- if .Values.metrics.enabled -}} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "app.fullname" . }} + labels: + {{- include "app.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "app.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: {{ .Values.metrics.path }} + interval: {{ .Values.metrics.interval }} +{{- end }} +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/tests/test-connection.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/tests/test-connection.yaml new file mode 100644 index 0000000..452e1a9 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/templates/tests/test-connection.yaml @@ -0,0 +1,17 @@ +{% raw %}apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "app.fullname" . }}-test-connection" + labels: + {{- include "app.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + restartPolicy: Never + containers: + - name: curl + image: curlimages/curl:latest + command: ["curl"] + args: ["-fsSL", "http://{{ include "app.fullname" . }}:{{ .Values.service.port }}/health/live"] +{% endraw %} \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values-dev.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values-dev.yaml new file mode 100644 index 0000000..72cee5c --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values-dev.yaml @@ -0,0 +1,22 @@ +replicaCount: 1 + +config: + logFormat: console + logLevel: DEBUG + otelTracesSamplerArg: "1.0" + +ingress: + enabled: true + hosts: + - host: {{ cookiecutter.project_slug }}.dev.example.com + paths: + - path: / + pathType: Prefix + +resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values-prod.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values-prod.yaml new file mode 100644 index 0000000..a78f511 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values-prod.yaml @@ -0,0 +1,39 @@ +replicaCount: 3 + +config: + logFormat: json + logLevel: WARNING + otelTracesSamplerArg: "0.1" + +ingress: + enabled: true + hosts: + - host: {{ cookiecutter.project_slug }}.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: {{ cookiecutter.project_slug }}-tls + hosts: + - {{ cookiecutter.project_slug }}.example.com + +resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 2000m + memory: 1Gi + +autoscaling: + enabled: true + minReplicas: 3 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + +podDisruptionBudget: + enabled: true + minAvailable: 2 + +metrics: + enabled: true diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values-staging.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values-staging.yaml new file mode 100644 index 0000000..1e372dc --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values-staging.yaml @@ -0,0 +1,23 @@ +replicaCount: 2 + +config: + logFormat: json + logLevel: INFO + otelTracesSamplerArg: "0.5" + +ingress: + enabled: true + hosts: + - host: {{ cookiecutter.project_slug }}.staging.example.com + paths: + - path: / + pathType: Prefix + +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 4 + +podDisruptionBudget: + enabled: true + minAvailable: 1 diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values.schema.json b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values.schema.json new file mode 100644 index 0000000..7e194e1 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values.schema.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "{{ cookiecutter.project_slug }} chart values", + "type": "object", + "required": ["image", "config"], + "properties": { + "replicaCount": { "type": "integer", "minimum": 1 }, + "terminationGracePeriodSeconds": { "type": "integer", "minimum": 1 }, + "image": { + "type": "object", + "required": ["repository"], + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }, + "tag": { "type": "string" } + } + }, + "service": { + "type": "object", + "properties": { + "type": { "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "config": { + "type": "object", + "required": ["logFormat", "logLevel", "serviceName"], + "properties": { + "logFormat": { "enum": ["console", "json"] }, + "logLevel": { "enum": ["DEBUG", "INFO", "WARNING", "ERROR"] }, + "serviceName": { "type": "string" }, + "otelExporterOtlpEndpoint": { "type": "string" }, + "otelTracesSamplerArg": { "type": "string" }, + "corsAllowedOrigins": { "type": "string" }, + "dbPoolSize": { "type": "string", "pattern": "^[0-9]+$" }, + "dbPoolMaxOverflow": { "type": "string", "pattern": "^[0-9]+$" }, + "dbPoolTimeout": { "type": "string", "pattern": "^[0-9]+$" }, + "requestMaxBodyBytes": { "type": "string", "pattern": "^[0-9]+$" }, + "requestTimeoutSeconds": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?$" }, + "idempotencyEnabled": { "enum": ["true", "false"] }, + "idempotencyMethods": { "type": "string" }, + "idempotencyTtlSeconds": { "type": "string", "pattern": "^[0-9]+$" }, + "authJwtAlgorithm": { "enum": ["HS256", "RS256"] }, + "authJwtJwksUrl": { "type": "string" }, + "authJwtAudience": { "type": "string" }, + "authJwtIssuer": { "type": "string" }, + "authJwtRolesClaim": { "type": "string" }, + "swaggerOauth2AuthorizationUrl": { "type": "string" }, + "swaggerOauth2TokenUrl": { "type": "string" }, + "swaggerOauth2ClientId": { "type": "string" }, + "swaggerOauth2Scopes": { "type": "string" }, + "swaggerOauth2PkceEnabled": { "enum": ["true", "false"] } + } + }, + "secrets": { + "type": "object", + "properties": { + "dbUrl": { "type": "string" }, + "authJwtSecret": { "type": "string" } + } + }, + "ingress": { "type": "object" }, + "resources": { "type": "object" }, + "autoscaling": { "type": "object" }, + "podDisruptionBudget": { "type": "object" }, + "probes": { "type": "object" }, + "migrations": { "type": "object" }, + "metrics": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "interval": { "type": "string" }, + "path": { "type": "string" }, + "alerts": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "labels": { "type": "object" }, + "thresholds": { + "type": "object", + "properties": { + "outboxBacklog": { "type": "integer", "minimum": 1 }, + "outboxLagSeconds": { "type": "integer", "minimum": 1 }, + "outboxHandlerErrorRate": { "type": "number", "minimum": 0 }, + "api5xxRate": { "type": "number", "minimum": 0, "maximum": 1 }, + "apiP99LatencySeconds": { "type": "number", "minimum": 0 } + } + } + } + } + } + }, + "relay": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "replicaCount": { "type": "integer", "minimum": 1 }, + "metricsPort": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "resources": { "type": "object" } + } + }, + "podSecurityContext": { "type": "object" }, + "securityContext": { "type": "object" }, + "serviceAccount": { "type": "object" }, + "imagePullSecrets": { "type": "array" }, + "podAnnotations": { "type": "object" }, + "podLabels": { "type": "object" }, + "nodeSelector": { "type": "object" }, + "tolerations": { "type": "array" }, + "affinity": { "type": "object" }, + "nameOverride": { "type": "string" }, + "fullnameOverride": { "type": "string" } + } +} diff --git a/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values.yaml b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values.yaml new file mode 100644 index 0000000..feb3744 --- /dev/null +++ b/{{cookiecutter.project_slug}}/helm/{{cookiecutter.project_slug}}/values.yaml @@ -0,0 +1,188 @@ +# Default values for {{ cookiecutter.project_slug }}. +# Per-environment overrides live in values-{dev,staging,prod}.yaml. +# Schema is enforced by values.schema.json (helm validates against it). + +replicaCount: 1 + +image: + repository: ghcr.io/{{ cookiecutter.author_name|lower }}/{{ cookiecutter.project_slug }} + pullPolicy: IfNotPresent + tag: "" # defaults to .Chart.AppVersion + +imagePullSecrets: [] + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + +service: + type: ClusterIP + port: 8000 + +ingress: + enabled: false + className: nginx + annotations: {} + hosts: + - host: {{ cookiecutter.project_slug }}.example.com + paths: + - path: / + pathType: Prefix + tls: [] + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 70 + +podDisruptionBudget: + enabled: false + minAvailable: 1 + +# Must stay strictly greater than the uvicorn `--timeout-graceful-shutdown` +# value baked into the Dockerfile (currently 30s) so K8s does not SIGKILL +# the pod before the in-flight requests have drained. +terminationGracePeriodSeconds: 35 + +probes: + liveness: + path: /health/live # process responsive — fail = pod restart + initialDelaySeconds: 10 + periodSeconds: 10 + readiness: + path: /health/ready # DB reachable — fail = removed from LB + initialDelaySeconds: 5 + periodSeconds: 5 + startup: + path: /health/startup # boot finished — guards live/ready timeouts + failureThreshold: 30 + periodSeconds: 5 + +# Application config — exposed as env vars or read from a Secret/ConfigMap. +config: + logFormat: json + logLevel: INFO + serviceName: {{ cookiecutter.package_name }} + otelExporterOtlpEndpoint: "" + otelTracesSamplerArg: "0.1" + corsAllowedOrigins: "" + # Database connection pool — tune per environment based on the expected + # concurrent request count vs. the database's max_connections budget. + dbPoolSize: "10" + dbPoolMaxOverflow: "5" + dbPoolTimeout: "30" # seconds to wait for a free connection + # HTTP middleware — request body size cap and per-request timeout. + requestMaxBodyBytes: "1048576" # 1 MiB + requestTimeoutSeconds: "30" + # Idempotency middleware (see building-a-feature § "Idempotency"). + idempotencyEnabled: "true" + idempotencyMethods: "POST,PUT,PATCH,DELETE" + idempotencyTtlSeconds: "86400" # 24h + # Auth — bearer-JWT verification. See the ``adding-auth`` skill for the + # swap path to a real IdP. + authJwtAlgorithm: "HS256" # HS256 | RS256 + authJwtJwksUrl: "" # required when authJwtAlgorithm=RS256 + authJwtAudience: "" # leave empty to disable the aud check + authJwtIssuer: "" # leave empty to disable the iss check + authJwtRolesClaim: "roles" # dotted path inside the JWT claims + # Swagger UI OAuth2 — when BOTH AuthorizationUrl and TokenUrl are set, + # /docs publishes an OAuth2 Authorization Code + PKCE flow (clickable + # Authorize button). Otherwise falls back to HTTPBearer paste-once. + # See docs/swagger-oauth2.md for the IdP app-registration walkthrough. + swaggerOauth2AuthorizationUrl: "" + swaggerOauth2TokenUrl: "" + swaggerOauth2ClientId: "" # ID of the SEPARATE Swagger-UI app on the IdP + swaggerOauth2Scopes: "openid,email,profile" + swaggerOauth2PkceEnabled: "true" + +# Secrets — by default empty; populate via a SealedSecret, ExternalSecret or +# raw values overlay outside source control. +secrets: + dbUrl: "" + authJwtSecret: "" # required when authJwtAlgorithm=HS256 + +# Migrations job — runs `alembic upgrade head` as a Helm pre-install/pre-upgrade +# hook. Disable for projects that don't manage their schema with Alembic. +migrations: + enabled: true + image: + repository: "" # defaults to .Values.image.repository + tag: "" + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +# Optional ServiceMonitor for Prometheus scraping of the API pod. +metrics: + enabled: false + interval: 30s + path: /metrics + # PrometheusRule with sensible defaults. Tune the thresholds to your + # traffic profile before going live — defaults err on the conservative + # side to avoid alert fatigue. See the ``building-a-feature`` § "Transactions and events" + # and ``writing-a-helm-change`` skills for the lifecycle of each alert. + alerts: + enabled: false + # Extra labels merged into every alert — drive Alertmanager routing + # (team → channel, severity → page). + labels: {} + thresholds: + outboxBacklog: 1000 # rows pending — backlog signal + outboxLagSeconds: 300 # oldest pending age — primary lag signal + outboxHandlerErrorRate: 0.1 # handler failures / sec sustained over 5m + api5xxRate: 0.01 # fraction of 5xx responses sustained over 5m + apiP99LatencySeconds: 1.0 # /metrics histogram p99 + +# Outbox relay — separate Deployment that polls outbox_events and dispatches +# rows through the in-process handler registry (see ADR 0005 + +# building-a-feature § "Transactions and events"). Same image as the API, different command. +# Turn off via relay.enabled=false on projects that don't publish events. +relay: + enabled: true + replicaCount: 1 # SKIP LOCKED is safe with >1; default kept conservative + metricsPort: 9100 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/{{cookiecutter.project_slug}}/justfile b/{{cookiecutter.project_slug}}/justfile new file mode 100644 index 0000000..924ec2e --- /dev/null +++ b/{{cookiecutter.project_slug}}/justfile @@ -0,0 +1,146 @@ +# {{ cookiecutter.project_name }} — command runner. +# Single source of truth for every command run by humans, Claude Code hooks, +# and CI. If a command is run more than once, it lives here. +# +# Install just: `cargo install just` / `brew install just` / `apt install just`. +# It is pre-installed in the devcontainer. + +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +# Default recipe: list everything with descriptions. +default: + @just --list --unsorted + +# ─── Setup ────────────────────────────────────────────────────────── +# Install runtime + dev dependencies via uv. +install: + uv sync --group dev + +# Install all pre-commit and git hooks (pre-commit + commit-msg + pre-push). +hooks: + uv run pre-commit install --install-hooks --hook-type pre-commit --hook-type commit-msg --hook-type pre-push + +# ─── Run ──────────────────────────────────────────────────────────── +# Start the FastAPI dev server with auto-reload. +dev: + uv run uvicorn {{ cookiecutter.package_name }}.presentation.api.app:app --reload + +# Mint a local HS256 JWT signed with AUTH_JWT_SECRET — for calling protected +# endpoints from curl / Postman during dev. +# Usage: just dev-token alice +# just dev-token alice admin,user +dev-token SUBJECT ROLES="": + uv run python -m {{ cookiecutter.package_name }}.infrastructure.auth.dev_token "{{ '{{' }}SUBJECT{{ '}}' }}" "{{ '{{' }}ROLES{{ '}}' }}" + +# ─── Test ─────────────────────────────────────────────────────────── +# Fast feedback loop: unit tests only, stop on first failure. +test: + uv run pytest -m unit -x -q + +# Watch mode: re-run unit tests on every file save (TDD inner loop). +# Quit with Ctrl-C. +test-watch: + uv run ptw --runner "uv run pytest -m unit -x -q" . + +# All tests (unit + integration + e2e). Requires Docker for testcontainers. +test-all: + uv run pytest + +# Parallel run of the full suite. Each xdist worker spawns its own +# testcontainers Postgres, so this only pays off once the suite is large +# enough that wall-clock savings beat per-worker container startup +# (rule of thumb: > ~100 integration/e2e tests on the machine). +test-parallel: + uv run pytest -n auto + +# Integration tests only. +test-integration: + uv run pytest -m integration + +# E2E tests only. +test-e2e: + uv run pytest -m e2e + +# Mutation testing on domain + application (the layers with logic worth mutating). +mutate: + uv run mutmut run + +# Show the latest mutmut report. +mutate-results: + uv run mutmut results + +# ─── Quality ──────────────────────────────────────────────────────── +# Aggregator: ruff + ty + the four custom architectural lints. +lint: + bash scripts/checks/quality.sh + +# Auto-format the codebase in place. +fmt: + uv run ruff format src/ tests/ scripts/ + uv run ruff check --fix src/ tests/ scripts/ + +# Static type check. +typecheck: + uv run ty check src/ + +# ─── Database ─────────────────────────────────────────────────────── +# Apply all pending migrations (retries briefly to absorb startup races). +migrate: + #!/usr/bin/env bash + set -e + i=0 + until uv run alembic upgrade head; do + i=$((i+1)) + if [ $i -ge 6 ]; then + echo "alembic upgrade head failed after 6 retries — is postgres reachable?" >&2 + exit 1 + fi + echo "alembic retrying in 2s (attempt $i/6)..." + sleep 2 + done + +{% raw %}# Generate a new revision from current model state. Pass a short description. +# Example: just migration "add_subscriptions_table" +migration message: + uv run alembic revision --autogenerate -m "{{ message }}" +{% endraw %} +# Roll back the most recent revision (review the produced SQL first). +migrate-down: + uv run alembic downgrade -1 + +# ─── Docker / Helm ────────────────────────────────────────────────── +# Build the production image locally as `app:dev`. +docker-build: + docker build --target runtime -t app:dev . + +# Bring the local devcontainer stack down (volumes included) and back up +# with a fresh build. Use this when you suspect the running containers +# hold a stale workspace mount or postgres-data volume — typical after +# re-baking the project into the same path or moving the project directory. +# The standard VSCode "Reopen in Container" workflow does NOT need this. +devcontainer-reset: + docker compose -f .devcontainer/docker-compose.yml down -v + docker compose -f .devcontainer/docker-compose.yml up -d --build + +{% raw %}# Render the chart with a chosen environment overlay (default: prod). +# Example: just helm-render dev +helm-render env="prod": + helm template t helm/{% endraw %}{{ cookiecutter.project_slug }}{% raw %} -f helm/{% endraw %}{{ cookiecutter.project_slug }}{% raw %}/values-{{ env }}.yaml --set secrets.dbUrl=postgresql+asyncpg://placeholder +{% endraw %} +# Lint the Helm chart. +helm-lint: + helm lint helm/{{ cookiecutter.project_slug }} +{%- if cookiecutter.frontend_sdk != "none" %} + +# ─── SDK ──────────────────────────────────────────────────────────── +# Export the OpenAPI schema to openapi.json (consumed by SDK gen workflow). +openapi: + uv run python scripts/export_openapi.py openapi.json +{%- endif %} + +# ─── CI surface (run locally what CI runs) ────────────────────────── +# What ci-quality.yml runs on PR. +ci-quality: lint test-all + +# What ci-mutation.yml runs on PR. +ci-mutation: mutate diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml new file mode 100644 index 0000000..ba7fca5 --- /dev/null +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -0,0 +1,201 @@ +[project] +name = "{{ cookiecutter.project_slug }}" +version = "0.1.0" +description = "{{ cookiecutter.project_description }}" +readme = "README.md" +requires-python = ">={{ cookiecutter.python_version }}" +authors = [{ name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" }] +{%- if cookiecutter.license == "MIT" %} +license = { text = "MIT" } +{%- elif cookiecutter.license == "Apache-2.0" %} +license = { text = "Apache-2.0" } +{%- else %} +license = { text = "Proprietary" } +{%- endif %} + +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "pydantic>=2.12", + "pydantic[email]>=2.12", + "pydantic-settings>=2.13", + "structlog>=24.4", + "sqlalchemy[asyncio]>=2.0", + "asyncpg>=0.30", + "aiosqlite>=0.20", + "alembic>=1.14", +{%- if cookiecutter.include_otel == "yes" %} + "opentelemetry-api>=1.29", + "opentelemetry-sdk>=1.29", + "opentelemetry-exporter-otlp>=1.29", + "opentelemetry-instrumentation-fastapi>=0.50b0", + "opentelemetry-instrumentation-sqlalchemy>=0.50b0", + "opentelemetry-instrumentation-asyncpg>=0.50b0", + "opentelemetry-instrumentation-httpx>=0.50b0", + "opentelemetry-instrumentation-logging>=0.50b0", +{%- endif %} + "httpx>=0.28", + "prometheus-client>=0.21", + "prometheus-fastapi-instrumentator>=7.0", + "pyjwt[crypto]>=2.10", +] + +[dependency-groups] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.25", + "pytest-cov>=6.0", + "pytest-watcher>=0.4", + "pytest-xdist>=3.6", + "httpx>=0.28", + "respx>=0.21", + "testcontainers[postgresql]>=4.9", + "mutmut>=3.2", + "ruff>=0.9", + "ty>=0.0.1a1", + "pre-commit>=4.0", + "commitizen>=4.1", + "bandit>=1.8", + "pip-audit>=2.9", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/{{ cookiecutter.package_name }}"] + +# ─── Ruff ────────────────────────────────────────────────────────────── +[tool.ruff] +line-length = 120 +target-version = "py{{ cookiecutter.python_version|replace('.', '') }}" +src = ["src", "tests"] + +[tool.ruff.lint] +# D2 ruleset. +select = [ + "E", "F", "I", "B", "S", # base + "UP", # pyupgrade + "ANN", # annotations + "ARG", # unused args + "RET", # explicit returns + "SIM", # simplifications + "TC", # TYPE_CHECKING imports (renamed from TCH in ruff 0.15) + "PTH", # pathlib + "T20", # no print + "ERA", # no commented-out code + "RUF", # ruff-specific + "C90", # cyclomatic + "D", # docstrings (Google) + "N", # PEP8 naming + "TID", # tidy imports + "PL", # pylint subset +] +ignore = [ + "D100", # Missing docstring in public module — module name is enough + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D107", # Missing docstring in __init__ + "D203", # 1 blank line before class docstring (conflicts with D211) + "D213", # Multi-line summary on second line (conflicts with D212) + "ANN401", # Any allowed at boundaries (parsing, structlog kwargs) + "B008", # Function call in default argument — FastAPI Depends() is idiomatic + "TC001", # TYPE_CHECKING imports — too aggressive, hurts readability + "TC002", + "TC003", +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101", "S105", "S106", "ANN", "D", "PLR2004", "ARG", "PLR0913"] +"alembic/versions/*" = ["D", "ERA"] +"scripts/**" = ["T20"] +# dev-token is a CLI tool that legitimately prints a freshly minted token +# to stdout — T201 (no-print) is wrong for this single file. +"src/{{ cookiecutter.package_name }}/infrastructure/auth/dev_token.py" = ["T20"] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +# Cross-layer imports are forbidden by Clean Architecture. +# Enforced more thoroughly by scripts/checks/no_third_party_in_domain.py. + +[tool.ruff.lint.isort] +# Keep ``tests.*`` imports in their own group, separated from the +# project package. Without this, the alphabetical order between +# ``tests`` and ``{{ cookiecutter.package_name }}`` depends on the +# package name's first letter — a baked project named ``zebra`` would +# sort imports differently from one named ``alpha``. Forcing the split +# makes the layout deterministic regardless of the chosen package name. +forced-separate = ["tests"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +# ─── ty (Astral type checker) ────────────────────────────────────────── +[tool.ty] +# Phase B starts non-strict to avoid false positives on Pydantic/SQLAlchemy. +# Phase D2 will set strict mode. + +# ─── pytest ──────────────────────────────────────────────────────────── +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers" +asyncio_mode = "auto" +# Session-scoped event loop so session-scoped async fixtures (pg_engine, +# pg_session) keep the same loop across tests. Without this, pytest-asyncio +# 0.25+ creates a new loop per test and SQLAlchemy raises +# "Future attached to a different loop". +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" +markers = [ + "unit: pure unit tests, no I/O, no testcontainers", + "integration: require testcontainers or external service mocks", + "e2e: full stack via httpx.AsyncClient", +] + +# ─── Coverage ────────────────────────────────────────────────────────── +[tool.coverage.run] +source = ["src/{{ cookiecutter.package_name }}"] +branch = true + +[tool.coverage.report] +fail_under = 95 +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@abstractmethod", +] + +# ─── mutmut ──────────────────────────────────────────────────────────── +# Mutmut 3.5+ requires LIST values, not strings, for paths_to_mutate and +# tests_dir. Without ``also_copy``, mutmut tries to clone "/sys/class/powercap" +# and other unrelated paths, producing FileNotFoundError on Linux hosts. +[tool.mutmut] +paths_to_mutate = [ + "src/{{ cookiecutter.package_name }}/domain", + "src/{{ cookiecutter.package_name }}/application", +] +also_copy = [ + "src/{{ cookiecutter.package_name }}/infrastructure", + "src/{{ cookiecutter.package_name }}/presentation", + "src/{{ cookiecutter.package_name }}/__init__.py", + "tests", + "pyproject.toml", + "uv.lock", + "alembic", + "alembic.ini", +] +runner = "uv run pytest -m unit -x -q" +tests_dir = ["tests/unit"] + +# ─── Commitizen ──────────────────────────────────────────────────────── +[tool.commitizen] +name = "cz_conventional_commits" +version_provider = "pep621" +update_changelog_on_bump = true +tag_format = "v$version" diff --git a/{{cookiecutter.project_slug}}/scripts/checks/__init__.py b/{{cookiecutter.project_slug}}/scripts/checks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/scripts/checks/no_naive_datetime.py b/{{cookiecutter.project_slug}}/scripts/checks/no_naive_datetime.py new file mode 100644 index 0000000..75cc62b --- /dev/null +++ b/{{cookiecutter.project_slug}}/scripts/checks/no_naive_datetime.py @@ -0,0 +1,65 @@ +"""Forbid naive ``datetime.now()`` / ``datetime.utcnow()`` calls outside tests. + +Naive datetimes break determinism (FIRST) and conflate time zones. The +domain and application layers must use the ``Clock`` abstraction; the +infrastructure and presentation layers must pass an explicit ``tz=`` argument. +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +UTCNOW_MSG = "'datetime.utcnow()' is forbidden (naive). Use 'datetime.now(tz=UTC)' or the Clock abstraction." +NAIVE_NOW_MSG = "naive 'datetime.now()' is forbidden. Pass 'tz=UTC' or use the Clock abstraction." + + +def _scan(file: Path) -> list[str]: + """Return the violation messages found in ``file``. + + Matches only calls whose receiver is the literal name ``datetime`` + (e.g. ``datetime.now()``, ``datetime.utcnow()``). It deliberately does + not match ``clock.now()`` or any other ``something.now()`` — those use + the Clock abstraction or a domain-specific API. + """ + issues: list[str] = [] + tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if not isinstance(func, ast.Attribute): + continue + receiver = func.value + if not (isinstance(receiver, ast.Name) and receiver.id == "datetime"): + continue + if func.attr == "utcnow": + issues.append(f"{file}:{node.lineno}: {UTCNOW_MSG}") + elif func.attr == "now": + has_tz = any(kw.arg == "tz" for kw in node.keywords) or bool(node.args) + if not has_tz: + issues.append(f"{file}:{node.lineno}: {NAIVE_NOW_MSG}") + return issues + + +def main(argv: list[str]) -> int: + """CLI entry point.""" + targets = [Path(p) for p in argv[1:]] or [Path("src")] + files: list[Path] = [] + for t in targets: + if t.is_dir(): + files.extend(p for p in t.rglob("*.py") if "tests" not in p.parts) + elif t.suffix == ".py": + files.append(t) + issues: list[str] = [] + for file in files: + issues.extend(_scan(file)) + if issues: + sys.stderr.write("\n".join(issues) + "\n") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/{{cookiecutter.project_slug}}/scripts/checks/no_third_party_in_domain.py b/{{cookiecutter.project_slug}}/scripts/checks/no_third_party_in_domain.py new file mode 100644 index 0000000..d4a31fb --- /dev/null +++ b/{{cookiecutter.project_slug}}/scripts/checks/no_third_party_in_domain.py @@ -0,0 +1,80 @@ +"""Forbid non-stdlib imports in ``src//domain/``. + +The domain layer must remain pure: zero third-party dependencies. This script +parses every ``.py`` file under ``src//domain/`` and reports any +``import`` statement that resolves to a non-stdlib top-level package. + +Exit codes: + 0 no violation + 1 violations found, paths listed on stderr + 2 invocation error (no files, broken AST) +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +DOMAIN_DIR_NAME = "domain" + +# Standard library names available in Python 3.13. We reuse ``sys.stdlib_module_names`` +# rather than maintain our own list. +STDLIB = set(sys.stdlib_module_names) +# The package being tested is always allowed (relative imports + absolute self-imports). +PROJECT_FIRST_PARTY: set[str] = set() + + +def _iter_domain_files(src_root: Path) -> list[Path]: + return [p for p in src_root.rglob("*.py") if DOMAIN_DIR_NAME in p.parts] + + +def _project_top_level(src_root: Path) -> str | None: + for child in src_root.iterdir(): + if child.is_dir() and (child / "__init__.py").exists(): + return child.name + return None + + +def _imports(file: Path) -> list[tuple[int, str]]: + tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file)) + imports: list[tuple[int, str]] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + imports.append((node.lineno, alias.name.split(".")[0])) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + imports.append((node.lineno, node.module.split(".")[0])) + return imports + + +def main(argv: list[str]) -> int: + """CLI entry point.""" + src_root = Path(argv[1]) if len(argv) > 1 else Path("src") + if not src_root.is_dir(): + sys.stderr.write(f"src directory not found: {src_root}\n") + return 2 + + top_level = _project_top_level(src_root) + if top_level: + PROJECT_FIRST_PARTY.add(top_level) + + files = _iter_domain_files(src_root) + if not files: + return 0 + + violations: list[str] = [] + for file in files: + for lineno, top in _imports(file): + if top in STDLIB or top in PROJECT_FIRST_PARTY: + continue + violations.append(f"{file}:{lineno}: forbidden third-party import in domain layer: '{top}'") + + if violations: + sys.stderr.write("\n".join(violations) + "\n") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/{{cookiecutter.project_slug}}/scripts/checks/quality.sh b/{{cookiecutter.project_slug}}/scripts/checks/quality.sh new file mode 100755 index 0000000..03b6620 --- /dev/null +++ b/{{cookiecutter.project_slug}}/scripts/checks/quality.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Aggregator invoked by: +# - .pre-commit-config.yaml (local) +# - .claude/hooks/pre-commit-gate.sh (Claude Code) +# - CI ci-quality.yml +# +# Fails fast on the first non-zero step. Each step is a single source of truth. + +set -euo pipefail + +step() { + printf '\n>> %s\n' "$1" +} + +step "ruff check" +uv run ruff check src/ tests/ scripts/ + +step "ruff format --check" +uv run ruff format --check src/ tests/ scripts/ + +step "ty check" +uv run ty check src/ + +step "no_third_party_in_domain" +uv run python scripts/checks/no_third_party_in_domain.py src/ + +step "no_naive_datetime" +uv run python scripts/checks/no_naive_datetime.py src/ + +step "test_naming" +uv run python scripts/checks/test_naming.py tests/ + +echo +echo "All quality checks passed." diff --git a/{{cookiecutter.project_slug}}/scripts/checks/test_naming.py b/{{cookiecutter.project_slug}}/scripts/checks/test_naming.py new file mode 100644 index 0000000..61069db --- /dev/null +++ b/{{cookiecutter.project_slug}}/scripts/checks/test_naming.py @@ -0,0 +1,100 @@ +"""Enforce GIVEN/WHEN/THEN test format. + +Two checks per test function: + 1. Function name matches ``test_given__when__then_``. + 2. Function body contains exactly the comments ``# GIVEN``, ``# WHEN``, ``# THEN`` + (or ``# WHEN / THEN`` combined when followed by ``pytest.raises``) in this + order, separated by blank lines. + +Strict for tests under ``tests/unit/`` and ``tests/integration/``. Looser for +``tests/e2e/`` where the GIVEN may be encoded in a fixture; only ``# WHEN`` +and ``# THEN`` are required there. +""" + +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + +NAME_RE = re.compile(r"^test_given_[a-z0-9_]+_when_[a-z0-9_]+_then_[a-z0-9_]+$") +WHEN_THEN_NAME_RE = re.compile(r"^test_[a-z0-9_]+$") # fallback for pytest.raises-only patterns +STRICT_PARTS = ("unit", "integration") + + +def _is_strict(file: Path) -> bool: + return any(part in STRICT_PARTS for part in file.parts) + + +def _is_e2e(file: Path) -> bool: + return "e2e" in file.parts + + +def _has_marker(text: str, marker: str) -> bool: + """Match a stripped line equal to ``marker`` or starting with ``marker:``. + + The colon-suffix form lets fixture-driven tests annotate the bare GIVEN + block without a body — ``# GIVEN: wired by fixture`` is a single-line + sentence that satisfies the marker requirement. + """ + target = marker.strip() + return any(line.strip() == target or line.strip().startswith(f"{target}:") for line in text.splitlines()) + + +def _check_function(file: Path, fn: ast.FunctionDef | ast.AsyncFunctionDef, source: str) -> list[str]: + issues: list[str] = [] + name = fn.name + if not name.startswith("test_"): + return [] + + body_text = ast.get_source_segment(source, fn) or "" + has_given = _has_marker(body_text, "# GIVEN") + has_when = _has_marker(body_text, "# WHEN") or _has_marker(body_text, "# WHEN / THEN") + has_then = _has_marker(body_text, "# THEN") or _has_marker(body_text, "# WHEN / THEN") + + if _is_strict(file): + if not NAME_RE.match(name): + issues.append( + f"{file}:{fn.lineno}: '{name}' must match test_given__when__then_." + ) + if not (has_given and has_when and has_then): + issues.append( + f"{file}:{fn.lineno}: '{name}' must contain '# GIVEN', '# WHEN' and '# THEN' comments " + "(or '# WHEN / THEN' combined when raising)." + ) + elif _is_e2e(file) and not (has_when and has_then): + issues.append(f"{file}:{fn.lineno}: e2e test '{name}' must contain '# WHEN' and '# THEN' comments.") + return issues + + +def _scan(file: Path) -> list[str]: + source = file.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(file)) + issues: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + issues.extend(_check_function(file, node, source)) + return issues + + +def main(argv: list[str]) -> int: + """CLI entry point.""" + targets = [Path(p) for p in argv[1:]] or [Path("tests")] + files: list[Path] = [] + for t in targets: + if t.is_dir(): + files.extend(p for p in t.rglob("test_*.py")) + elif t.suffix == ".py": + files.append(t) + issues: list[str] = [] + for file in files: + issues.extend(_scan(file)) + if issues: + sys.stderr.write("\n".join(issues) + "\n") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/{{cookiecutter.project_slug}}/scripts/export_openapi.py b/{{cookiecutter.project_slug}}/scripts/export_openapi.py new file mode 100644 index 0000000..9dea948 --- /dev/null +++ b/{{cookiecutter.project_slug}}/scripts/export_openapi.py @@ -0,0 +1,42 @@ +"""Export the FastAPI OpenAPI schema as a JSON file. + +Usage: + uv run python scripts/export_openapi.py [output_path] + just openapi + +Defaults to ``openapi.json`` at the project root. Used by: +- the typescript SDK generation workflow +- documentation site builders that consume the schema + +The script avoids touching the database: it sets a placeholder ``DB_URL`` +before importing the app so Pydantic Settings validation succeeds without +actually connecting (``create_app`` only inspects routes; the lifespan that +opens the engine never runs). +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +os.environ.setdefault("DB_URL", "sqlite+aiosqlite:///:memory:") + +from {{ cookiecutter.package_name }}.presentation.api.app import create_app + +DEFAULT_OUTPUT = Path("openapi.json") + + +def main(argv: list[str]) -> int: + """Write the OpenAPI schema to ``argv[1]`` (or ``openapi.json``).""" + output = Path(argv[1]) if len(argv) > 1 else DEFAULT_OUTPUT + app = create_app() + schema = app.openapi() + output.write_text(json.dumps(schema, indent=2, sort_keys=True), encoding="utf-8") + sys.stdout.write(f"Wrote OpenAPI schema to {output}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py new file mode 100644 index 0000000..895bb72 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py @@ -0,0 +1,3 @@ +"""{{ cookiecutter.project_name }}.""" + +__version__ = "0.1.0" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/__init__.py new file mode 100644 index 0000000..42e2ee6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/__init__.py @@ -0,0 +1,5 @@ +"""Application layer. + +Use cases and the abstractions they depend on (repositories, Clock, +IdGenerator, EventBus, AuditLog, ...). Imports `domain` only. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/audit_log.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/audit_log.py new file mode 100644 index 0000000..00ef387 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/audit_log.py @@ -0,0 +1,24 @@ +"""Audit log abstraction for user-meaningful events. + +Use cases call ``AuditLog.record(...)`` to persist a business audit entry. +The default infrastructure implementation writes to a relational ``audit_log`` +table. Projects that don't need auditing wire ``NullAuditLog``. +""" + +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID + + +class AuditLog(Protocol): + """Structural contract for a sink of audit events surfaced to end users / ops.""" + + async def record( + self, + event_name: str, + actor_id: UUID | None, + payload: dict[str, Any], + ) -> None: + """Persist an audit entry.""" + ... diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/auth/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/auth/__init__.py new file mode 100644 index 0000000..5995558 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/auth/__init__.py @@ -0,0 +1,6 @@ +"""Authentication contracts (CurrentUser, TokenVerifier). + +Auth identity is intentionally separate from the ``User`` entity. A user +of the service is identified by the IdP-issued token; the User business +entity is whatever the project models. The two may overlap or not. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/auth/current_user.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/auth/current_user.py new file mode 100644 index 0000000..5fd8fde --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/auth/current_user.py @@ -0,0 +1,48 @@ +"""Immutable identity of the caller of an HTTP request.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from {{ cookiecutter.package_name }}.domain.value_objects.base import ValueObject + + +@dataclass(frozen=True, slots=True) +class CurrentUser(ValueObject): + """The caller's identity as resolved from the bearer token. + + ``subject`` is the IdP-issued stable identifier (``sub`` claim). It is + NOT necessarily the ``UserId`` of the ``User`` entity — projects map + one to the other in the application layer if they need to. + + ``roles`` are the names of authz roles attached to the token (typically + ``realm_access.roles`` for Keycloak, ``"https:///roles"`` for Auth0). + The path is configurable in ``infrastructure/auth/jwt_verifier.py``. + + ``email`` and ``name`` are best-effort: not every IdP issues them by + default (Azure AD requires the ``email`` / ``profile`` scopes; Auth0 + namespaces custom claims). They are optional and never relied upon + for identity decisions — use ``subject`` for that. + + Attributes: + subject: stable opaque identifier from the IdP (``sub`` claim). + email: optional email associated with the principal. + name: optional display name associated with the principal. + roles: authz roles attached to the principal. + claims: full raw claims dict — for projects that need access to + non-standard IdP claims without altering this VO. + """ + + subject: str + email: str | None = None + name: str | None = None + roles: tuple[str, ...] = () + claims: dict[str, object] = field(default_factory=dict) + + def has_role(self, role: str) -> bool: + """Return whether the principal has the given role.""" + return role in self.roles + + def has_any_role(self, *roles: str) -> bool: + """Return whether the principal has at least one of the given roles.""" + return any(role in self.roles for role in roles) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/auth/token_verifier.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/auth/token_verifier.py new file mode 100644 index 0000000..423f80b --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/auth/token_verifier.py @@ -0,0 +1,24 @@ +"""``TokenVerifier`` protocol. + +Production binding lives in :mod:`{{ cookiecutter.package_name }}.infrastructure.auth.jwt_verifier`. +Tests inject :class:`tests.fakes.auth.FakeTokenVerifier`. +""" + +from __future__ import annotations + +from typing import Protocol + +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser + + +class TokenVerifier(Protocol): + """Structural contract for a bearer-token verifier.""" + + def verify(self, token: str) -> CurrentUser: + """Validate ``token`` and return the caller identity. + + Raises: + InvalidTokenError: the token failed verification (bad signature, + expired, wrong audience/issuer, malformed). + """ + ... diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/clock.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/clock.py new file mode 100644 index 0000000..91580a3 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/clock.py @@ -0,0 +1,19 @@ +"""Clock abstraction. + +The domain and the use cases never call ``datetime.now()`` directly because it +breaks test determinism (FIRST: tests must be Repeatable). They depend on this +``Clock`` protocol; the production binding lives in ``infrastructure/clock.py``. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Protocol + + +class Clock(Protocol): + """Structural contract for a source of the current wall-clock time.""" + + def now(self) -> datetime: + """Return the current time as a TZ-aware UTC datetime.""" + ... diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/dtos/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/dtos/__init__.py new file mode 100644 index 0000000..66397b7 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/dtos/__init__.py @@ -0,0 +1,5 @@ +"""Use case input/output DTOs. + +Each use case ships its ``...Input`` and ``...Output`` ``@dataclass(frozen=True, +slots=True)`` here. DTOs carry data only — never logic. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/dtos/user.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/dtos/user.py new file mode 100644 index 0000000..b7c2e12 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/dtos/user.py @@ -0,0 +1,32 @@ +"""DTOs for user use cases.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser + + +@dataclass(frozen=True, slots=True) +class EnsureUserExistsInput: + """Input DTO for ``EnsureUserExistsUseCase``. + + The single field is the verified ``CurrentUser`` carrying the JWT + subject, email and name. The use case treats this as the authoritative + identity source for SSO auto-provisioning. + """ + + current_user: CurrentUser + + +@dataclass(frozen=True, slots=True) +class EnsureUserExistsOutput: + """Output DTO for ``EnsureUserExistsUseCase`` — the persisted user.""" + + id: UUID + subject: str + email: str + name: str + created_at: datetime diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/event_bus.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/event_bus.py new file mode 100644 index 0000000..1341468 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/event_bus.py @@ -0,0 +1,13 @@ +"""Event bus abstraction for domain event propagation.""" + +from __future__ import annotations + +from typing import Any, Protocol + + +class EventBus(Protocol): + """Structural contract for a publisher of domain events.""" + + async def publish(self, event_name: str, payload: dict[str, Any]) -> None: + """Publish a domain event to subscribers.""" + ... diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/id_generator.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/id_generator.py new file mode 100644 index 0000000..679da39 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/id_generator.py @@ -0,0 +1,19 @@ +"""Identifier generator abstraction. + +The domain never calls ``uuid.uuid4()`` directly: it depends on this +``IdGenerator`` protocol. Production uses ``Uuid4IdGenerator``; tests use +``SequentialIdGenerator`` to make outputs deterministic. +""" + +from __future__ import annotations + +from typing import Protocol +from uuid import UUID + + +class IdGenerator(Protocol): + """Structural contract for a generator of unique identifiers.""" + + def new(self) -> UUID: + """Return a new globally unique identifier.""" + ... diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/random_source.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/random_source.py new file mode 100644 index 0000000..55d8c9a --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/random_source.py @@ -0,0 +1,20 @@ +"""Random source abstraction. + +The domain never calls ``random.*`` directly. Tests inject a seeded source. +""" + +from __future__ import annotations + +from typing import Protocol + + +class RandomSource(Protocol): + """Structural contract for a source of randomness.""" + + def next_int(self, low: int, high: int) -> int: + """Return an integer in the closed interval ``[low, high]``.""" + ... + + def next_float(self) -> float: + """Return a float in ``[0.0, 1.0)``.""" + ... diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/repositories/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/repositories/__init__.py new file mode 100644 index 0000000..884cff0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/repositories/__init__.py @@ -0,0 +1,7 @@ +"""Abstract repositories. + +One file per entity. The class name is the unsuffixed stereotype +(``UserRepository``), not ``UserRepositoryPort``. Concrete implementations +live in ``infrastructure/persistence/_repository.py`` and are prefixed +by mechanism (``SqlAlchemyUserRepository``). +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/repositories/base.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/repositories/base.py new file mode 100644 index 0000000..0a11337 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/repositories/base.py @@ -0,0 +1,22 @@ +"""Base repository protocol.""" + +from __future__ import annotations + +from typing import Protocol + + +class Repository[TEntity, TId](Protocol): + """Generic persistence contract for an entity. + + Concrete implementations provide ``add`` and ``find_by_id`` and may add + domain-specific finders (``find_by_email``, ``search_active``, ...). Sub- + protocols inherit and declare those extra methods (see ``UserRepository``). + """ + + async def add(self, entity: TEntity, /) -> None: + """Persist ``entity``, raising a ``DomainError`` on uniqueness violation.""" + ... + + async def find_by_id(self, id: TId, /) -> TEntity | None: + """Return the entity with the given identifier or ``None`` when absent.""" + ... diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/repositories/user.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/repositories/user.py new file mode 100644 index 0000000..3f4864f --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/repositories/user.py @@ -0,0 +1,38 @@ +"""``UserRepository`` protocol.""" + +from __future__ import annotations + +from typing import Protocol + +from {{ cookiecutter.package_name }}.application.repositories.base import Repository +from {{ cookiecutter.package_name }}.domain.entities.user import User +from {{ cookiecutter.package_name }}.domain.value_objects.email import Email +from {{ cookiecutter.package_name }}.domain.value_objects.user_id import UserId + + +class UserRepository(Repository[User, UserId], Protocol): + """Persistence contract for the ``User`` entity. + + ``add`` and ``find_by_id`` are inherited from ``Repository``. The + user-specific ``find_by_subject`` is the primary lookup path: + ``EnsureUserExistsUseCase`` calls it on every authenticated request + to map the JWT subject to the internal ``User`` row. + """ + + async def find_by_subject(self, subject: str) -> User | None: + """Return the user whose IdP ``subject`` matches, or ``None``. + + ``subject`` is the value of the JWT ``sub`` claim — opaque, + stable, and unique across users (a UNIQUE constraint enforces + this at the DB level). Used by ``EnsureUserExistsUseCase`` to + map a verified JWT to its persisted ``User`` row. + """ + ... + + async def find_by_email(self, email: Email) -> User | None: + """Return the user matching ``email`` or ``None``. + + Kept as a secondary lookup for admin / migration flows. Not used + by the SSO auto-provisioning path — that one uses ``subject``. + """ + ... diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/use_cases/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/use_cases/__init__.py new file mode 100644 index 0000000..7718703 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/use_cases/__init__.py @@ -0,0 +1,7 @@ +"""Use cases. + +One file = one use case. The class name keeps the ``UseCase`` suffix as a +deliberate role marker (``EnsureUserExistsUseCase``, +``RefundCustomerUseCase``). The public method is always +``execute(input: Input) -> Output``. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/use_cases/base.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/use_cases/base.py new file mode 100644 index 0000000..1485d49 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/use_cases/base.py @@ -0,0 +1,19 @@ +"""Base protocol for use cases.""" + +from __future__ import annotations + +from typing import Protocol + + +class UseCase[TInput, TOutput](Protocol): + """Single-responsibility orchestrator with one async entry point. + + Implementations provide ``execute(input)``. Cross-cutting concerns + (tracing, structured logging) are added by the DI container via the + ``_instrumented`` wrapper — never inside the use case itself, to keep + the application layer free of observability imports. + """ + + async def execute(self, input: TInput) -> TOutput: + """Run the use case against ``input`` and return the produced output.""" + ... diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/use_cases/ensure_user_exists.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/use_cases/ensure_user_exists.py new file mode 100644 index 0000000..b2d7b71 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/application/use_cases/ensure_user_exists.py @@ -0,0 +1,83 @@ +"""``EnsureUserExistsUseCase`` — SSO lazy auto-provisioning. + +Called from ``Depends(get_or_provision_user)`` on every authenticated +request. The first time a JWT subject is seen, a ``User`` row is created +from the JWT claims; subsequent requests reuse the existing row. +""" + +from __future__ import annotations + +from {{ cookiecutter.package_name }}.application.clock import Clock +from {{ cookiecutter.package_name }}.application.dtos.user import ( + EnsureUserExistsInput, + EnsureUserExistsOutput, +) +from {{ cookiecutter.package_name }}.application.event_bus import EventBus +from {{ cookiecutter.package_name }}.application.id_generator import IdGenerator +from {{ cookiecutter.package_name }}.application.repositories.user import UserRepository +from {{ cookiecutter.package_name }}.application.use_cases.base import UseCase +from {{ cookiecutter.package_name }}.domain.entities.user import User +from {{ cookiecutter.package_name }}.domain.exceptions.auth import MissingProfileClaimsError +from {{ cookiecutter.package_name }}.domain.value_objects.email import Email +from {{ cookiecutter.package_name }}.domain.value_objects.user_id import UserId + + +class EnsureUserExistsUseCase(UseCase[EnsureUserExistsInput, EnsureUserExistsOutput]): + """Map a verified JWT subject to a persisted ``User`` row, creating if needed.""" + + def __init__( + self, + *, + users: UserRepository, + clock: Clock, + ids: IdGenerator, + events: EventBus, + ) -> None: + self._users = users + self._clock = clock + self._ids = ids + self._events = events + + async def execute(self, input: EnsureUserExistsInput) -> EnsureUserExistsOutput: + """Find the user for ``current_user.subject`` or provision one.""" + current = input.current_user + existing = await self._users.find_by_subject(current.subject) + if existing is not None: + return _to_output(existing) + + if not current.email or not current.name: + raise MissingProfileClaimsError( + subject=current.subject, + has_email=bool(current.email), + has_name=bool(current.name), + ) + + user = User( + id=UserId(self._ids.new()), + subject=current.subject, + email=Email(current.email), + name=current.name, + created_at=self._clock.now(), + ) + await self._users.add(user) + await self._events.publish( + "user.provisioned", + { + "user_id": str(user.id.value), + "subject": user.subject, + "email": str(user.email), + "name": user.name, + "created_at": user.created_at.isoformat(), + }, + ) + return _to_output(user) + + +def _to_output(user: User) -> EnsureUserExistsOutput: + return EnsureUserExistsOutput( + id=user.id.value, + subject=user.subject, + email=str(user.email), + name=user.name, + created_at=user.created_at, + ) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/__init__.py new file mode 100644 index 0000000..c30f5b4 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/__init__.py @@ -0,0 +1,8 @@ +"""Domain layer. + +Contains the core business model: entities, value objects, domain exceptions. +This layer has zero dependencies on third parties and zero non-deterministic +side effects (no datetime.now(), no uuid.uuid4(), no random). Anything +non-deterministic comes through abstractions (Clock, IdGenerator, RandomSource) +defined in the application layer. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/entities/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/entities/__init__.py new file mode 100644 index 0000000..f34b03e --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/entities/__init__.py @@ -0,0 +1,7 @@ +"""Domain entities. + +Entities have identity. They are regular Python classes (NOT +``@dataclass``) with explicit ``__init__``, an ``id`` attribute, and +identity-based ``__eq__``/``__hash__``. Mutations go through named methods +(``order.cancel()``), never direct attribute assignment. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/entities/base.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/entities/base.py new file mode 100644 index 0000000..78cd9f8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/entities/base.py @@ -0,0 +1,24 @@ +"""Base class for entities.""" + +from __future__ import annotations + + +class Entity[TId]: + """Domain entity with identity-based equality. + + Subclasses set ``self.id: TId`` in their ``__init__`` and inherit + ``__eq__`` / ``__hash__`` keyed on ``(type, id)``. Mutations must go + through named methods (``order.cancel()``), never direct attribute + assignment. + """ + + id: TId + + def __eq__(self, other: object) -> bool: + return type(other) is type(self) and getattr(other, "id", None) == self.id + + def __hash__(self) -> int: + return hash((type(self), self.id)) + + def __repr__(self) -> str: + return f"{type(self).__name__}(id={self.id!r})" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/entities/user.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/entities/user.py new file mode 100644 index 0000000..c08bd90 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/entities/user.py @@ -0,0 +1,42 @@ +"""``User`` entity.""" + +from __future__ import annotations + +from datetime import datetime + +from {{ cookiecutter.package_name }}.domain.entities.base import Entity +from {{ cookiecutter.package_name }}.domain.value_objects.email import Email +from {{ cookiecutter.package_name }}.domain.value_objects.user_id import UserId + + +class User(Entity[UserId]): + """A registered user. + + Identity-based equality (``__eq__`` / ``__hash__``) is inherited from + ``Entity[UserId]``. Mutations go through named methods, never direct + attribute assignment. + + ``subject`` is the stable identifier issued by the IdP (the JWT + ``sub`` claim). It is unique per user and used by + ``EnsureUserExistsUseCase`` to map a JWT to its persisted ``User`` + row on every authenticated request. ``id`` remains an internal UUID + so an IdP migration does not break references. + """ + + def __init__( + self, + *, + id: UserId, + subject: str, + email: Email, + name: str, + created_at: datetime, + ) -> None: + self.id = id + self.subject = subject + self.email = email + self.name = name + self.created_at = created_at + + def __repr__(self) -> str: + return f"User(id={self.id!r}, subject={self.subject!r})" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/__init__.py new file mode 100644 index 0000000..1c6df65 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/__init__.py @@ -0,0 +1,11 @@ +"""Domain exceptions. + +All exceptions raised by the domain inherit from DomainError. Subclasses +carry a machine-readable code and human-readable message. The HTTP mapping +lives in presentation/api/error_handlers.py and is enforced by a test that +iterates DomainError.__subclasses__(). +""" + +from {{ cookiecutter.package_name }}.domain.exceptions.base import DomainError + +__all__ = ["DomainError"] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/auth.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/auth.py new file mode 100644 index 0000000..58a3c99 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/auth.py @@ -0,0 +1,53 @@ +"""Auth-related domain exceptions. + +These live in ``domain/exceptions/`` even though the validation runs in +``presentation/`` because they describe a business outcome (the caller is +not authorized to perform the operation). The HTTP status mapping lives in +``presentation/api/error_handlers.py``. +""" + +from __future__ import annotations + +from {{ cookiecutter.package_name }}.domain.exceptions.base import DomainError + + +class MissingTokenError(DomainError): + """Raised when a protected route is hit without an ``Authorization`` header.""" + + code = "AUTH_MISSING_TOKEN" + default_message = "Authentication is required to access this resource." + + +class InvalidTokenError(DomainError): + """Raised when the bearer token cannot be verified. + + Catch-all for the verifier: bad signature, expired token, wrong + audience / issuer, malformed payload. The cause is logged in ``context`` + but never exposed to the caller (to limit oracle-style leaks). + """ + + code = "AUTH_INVALID_TOKEN" + default_message = "Authentication credentials are invalid or expired." + + +class InsufficientPermissionsError(DomainError): + """Raised when an authenticated caller lacks a required role / scope.""" + + code = "AUTH_INSUFFICIENT_PERMISSIONS" + default_message = "You do not have permission to perform this action." + + +class MissingProfileClaimsError(DomainError): + """Raised when the JWT lacks the profile claims required to provision a User. + + The SSO auto-provisioning path requires ``email`` and ``name`` claims + in the verified JWT. If the IdP does not issue them (typical when the + Azure / Auth0 / Okta app is missing the ``email`` or ``profile`` + scope), the request is treated as invalid identity — the caller + cannot be mapped to a persisted ``User`` row. + """ + + code = "AUTH_MISSING_PROFILE_CLAIMS" + default_message = ( + "The bearer token is missing required profile claims (email, name)." + ) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/base.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/base.py new file mode 100644 index 0000000..6c4ad6f --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/base.py @@ -0,0 +1,35 @@ +"""Base domain exception.""" + +from __future__ import annotations + +from typing import Any, ClassVar + + +class DomainError(Exception): + """Base for all business-meaningful exceptions raised by the domain. + + Subclasses MUST set ``code`` (a stable SCREAMING_SNAKE machine + identifier) and ``default_message`` (an English, end-user safe sentence). + The presentation layer translates ``DomainError`` into HTTP responses + via ``ERROR_HTTP_MAPPING``; the DI container's ``_instrumented`` wrapper + serializes ``code`` and ``context`` into structured warning logs. + + Class attributes: + code: stable machine identifier (e.g. ``USER_ALREADY_EXISTS``). + default_message: fallback message when the caller does not provide one. + + Instance attributes: + message: human-readable English message safe to show to an end user. + context: dict of additional fields surfaced in technical logs. + """ + + code: ClassVar[str] = "DOMAIN_ERROR" + default_message: ClassVar[str] = "A business rule was violated." + + def __init__(self, message: str | None = None, **context: Any) -> None: + self.message: str = message or self.default_message + self.context: dict[str, Any] = dict(context) + super().__init__(self.message) + + def __repr__(self) -> str: + return f"{type(self).__name__}(code={self.code!r}, context={self.context!r})" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/user.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/user.py new file mode 100644 index 0000000..34db196 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/exceptions/user.py @@ -0,0 +1,19 @@ +"""User-related domain exceptions.""" + +from __future__ import annotations + +from {{ cookiecutter.package_name }}.domain.exceptions.base import DomainError + + +class UserAlreadyExistsError(DomainError): + """Raised when attempting to create a user with an email already in use.""" + + code = "USER_ALREADY_EXISTS" + default_message = "An account with this email already exists." + + +class UserNotFoundError(DomainError): + """Raised when looking up a user that does not exist.""" + + code = "USER_NOT_FOUND" + default_message = "No user found for the given identifier." diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/__init__.py new file mode 100644 index 0000000..e48e2c8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/__init__.py @@ -0,0 +1,6 @@ +"""Value objects. + +Value objects are ``@dataclass(frozen=True, slots=True)`` with validation in +``__post_init__``. Equality is by value (inherited from dataclass). Examples: +``Email``, ``Money``, ``UserId``. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/base.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/base.py new file mode 100644 index 0000000..434c0a5 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/base.py @@ -0,0 +1,13 @@ +"""Marker base for value objects.""" + + +class ValueObject: + """Marker class for value objects. + + Concrete subclasses MUST be declared with + ``@dataclass(frozen=True, slots=True)`` for value-based equality and + immutability. Inheriting from this class with ``__slots__ = ()`` keeps + the slotted layout intact. + """ + + __slots__ = () diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/email.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/email.py new file mode 100644 index 0000000..a868e35 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/email.py @@ -0,0 +1,37 @@ +"""``Email`` value object.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from {{ cookiecutter.package_name }}.domain.exceptions.base import DomainError +from {{ cookiecutter.package_name }}.domain.value_objects.base import ValueObject + +# Pragmatic RFC-ish regex. Strict RFC 5321 parsing is out of scope; this catches +# the vast majority of typos at boundary entry without false-rejecting valid +# corporate addresses. +_EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$") + + +class InvalidEmailError(DomainError): + """Raised when an ``Email`` is constructed from a malformed string.""" + + code = "INVALID_EMAIL" + default_message = "The email address is not valid." + + +@dataclass(frozen=True, slots=True) +class Email(ValueObject): + """Validated email address. Stored lowercase to enforce case-insensitive equality.""" + + value: str + + def __post_init__(self) -> None: + if not _EMAIL_RE.match(self.value): + raise InvalidEmailError(value=self.value) + # Force lowercase normalisation through __setattr__ since the dataclass is frozen. + object.__setattr__(self, "value", self.value.lower()) + + def __str__(self) -> str: + return self.value diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/user_id.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/user_id.py new file mode 100644 index 0000000..b539241 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/domain/value_objects/user_id.py @@ -0,0 +1,18 @@ +"""``UserId`` value object wrapping a ``UUID``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from {{ cookiecutter.package_name }}.domain.value_objects.base import ValueObject + + +@dataclass(frozen=True, slots=True) +class UserId(ValueObject): + """Strongly-typed wrapper around a user identifier UUID.""" + + value: UUID + + def __str__(self) -> str: + return str(self.value) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/__init__.py new file mode 100644 index 0000000..8856572 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/__init__.py @@ -0,0 +1,6 @@ +"""Infrastructure layer. + +Concrete implementations of application abstractions: persistence +(SQLAlchemy), HTTP clients, observability, configuration, the DI container. +Imports `domain` and `application`. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/auth/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/auth/__init__.py new file mode 100644 index 0000000..2dfb57b --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/auth/__init__.py @@ -0,0 +1,5 @@ +"""Production auth bindings. + +Currently ships :class:`JwtTokenVerifier` (HS256 or RS256 + JWKS). Swap by +editing :func:`{{ cookiecutter.package_name }}.infrastructure.container.build_token_verifier`. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/auth/dev_token.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/auth/dev_token.py new file mode 100644 index 0000000..ae80eae --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/auth/dev_token.py @@ -0,0 +1,66 @@ +"""Mint a short-lived HS256 JWT for local dev calls. + +Invoked via ``just dev-token [roles]``. NEVER usable in prod — +relies on ``AUTH_JWT_ALGORITHM=HS256`` and the local ``AUTH_JWT_SECRET``. +Prints a token good for one hour. The ``roles`` argument is a comma- +separated list and lands under the claim path configured by +``AUTH_JWT_ROLES_CLAIM`` (default ``roles``). +""" + +from __future__ import annotations + +import sys +from datetime import UTC, datetime, timedelta +from typing import Any + +import jwt + +from {{ cookiecutter.package_name }}.infrastructure.config.settings import get_settings + + +def _set_nested(claims: dict[str, Any], path: str, value: Any) -> None: + """Set ``claims[a][b][c] = value`` given dotted ``path = 'a.b.c'``.""" + cursor: dict[str, Any] = claims + parts = path.split(".") + for segment in parts[:-1]: + cursor = cursor.setdefault(segment, {}) + cursor[parts[-1]] = value + + +def main(argv: list[str]) -> int: + """Print a freshly signed HS256 token for ``argv = [subject, roles_csv?]``.""" + if not argv: + print("usage: dev-token [roles_csv]", file=sys.stderr) + return 2 + subject = argv[0] + roles_csv = argv[1] if len(argv) > 1 else "" + roles = [r.strip() for r in roles_csv.split(",") if r.strip()] + + settings = get_settings() + if settings.auth_jwt_algorithm != "HS256": + print(f"dev-token only supports HS256; AUTH_JWT_ALGORITHM={settings.auth_jwt_algorithm}", file=sys.stderr) + return 2 + if settings.auth_jwt_secret is None: + print("AUTH_JWT_SECRET is not set; populate it in .env first", file=sys.stderr) + return 2 + + now = datetime.now(tz=UTC) + claims: dict[str, Any] = { + "sub": subject, + "iat": now, + "exp": now + timedelta(hours=1), + } + if settings.auth_jwt_audience: + claims["aud"] = settings.auth_jwt_audience + if settings.auth_jwt_issuer: + claims["iss"] = settings.auth_jwt_issuer + if roles: + _set_nested(claims, settings.auth_jwt_roles_claim, roles) + + token = jwt.encode(claims, settings.auth_jwt_secret.get_secret_value(), algorithm="HS256") + print(token) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/auth/jwt_verifier.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/auth/jwt_verifier.py new file mode 100644 index 0000000..8ccb261 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/auth/jwt_verifier.py @@ -0,0 +1,131 @@ +"""JWT-based ``TokenVerifier`` binding. + +Two algorithms are supported out of the box, chosen via +``Settings.auth_jwt_algorithm``: + +* ``HS256`` — symmetric, shared secret. Simple for internal-only services + and dev. The secret comes from ``Settings.auth_jwt_secret``. +* ``RS256`` — asymmetric, public keys fetched from a JWKS URL. The standard + for a real IdP (Keycloak, Auth0, Okta, AWS Cognito, …). The JWKS URL + comes from ``Settings.auth_jwt_jwks_url`` and keys are cached by PyJWT. + +Audience and issuer are checked when configured (``auth_jwt_audience``, +``auth_jwt_issuer``). Leaving them empty disables the check. + +The role-extraction path is configurable via ``auth_jwt_roles_claim`` — +defaults to ``"roles"`` (flat top-level list). For Keycloak set it to +``"realm_access.roles"``; for Auth0 the namespaced claim +``"https:///roles"``. + +Swap to a different IdP: change this file's body or replace the binding in +``infrastructure/container.build_token_verifier``. Nothing else needs to +change. +""" + +from __future__ import annotations + +from typing import Any + +import jwt +from jwt import PyJWKClient + +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser +from {{ cookiecutter.package_name }}.domain.exceptions.auth import InvalidTokenError + + +class JwtTokenVerifier: + """Verify a bearer JWT and project it onto ``CurrentUser``.""" + + def __init__( # noqa: PLR0913 — every kw flows from Settings.auth_jwt_* + self, + *, + algorithm: str, + secret: str | None = None, + jwks_url: str | None = None, + audience: str | None = None, + issuer: str | None = None, + roles_claim: str = "roles", + ) -> None: + if algorithm == "HS256": + if not secret: + raise ValueError("HS256 requires Settings.auth_jwt_secret to be set.") + elif algorithm == "RS256": + if not jwks_url: + raise ValueError("RS256 requires Settings.auth_jwt_jwks_url to be set.") + else: + raise ValueError(f"Unsupported auth_jwt_algorithm={algorithm!r}; use HS256 or RS256.") + + self._algorithm = algorithm + self._secret = secret + self._audience = audience + self._issuer = issuer + self._roles_claim = roles_claim + # PyJWKClient caches keys with a 5 min default lifespan; safe to reuse. + self._jwks_client = PyJWKClient(jwks_url) if jwks_url else None + + def verify(self, token: str) -> CurrentUser: + """Decode + validate the token, returning the caller identity.""" + try: + claims = self._decode(token) + except jwt.InvalidTokenError as exc: + raise InvalidTokenError(reason=type(exc).__name__) from exc + + subject = claims.get("sub") + if not isinstance(subject, str) or not subject: + raise InvalidTokenError(reason="missing_sub_claim") + + email_value = claims.get("email") + email = email_value if isinstance(email_value, str) else None + + name_value = claims.get("name") + name = name_value if isinstance(name_value, str) else None + + return CurrentUser( + subject=subject, + email=email, + name=name, + roles=_extract_roles(claims, self._roles_claim), + claims=claims, + ) + + def _decode(self, token: str) -> dict[str, Any]: + if self._algorithm == "HS256": + return jwt.decode( + token, + self._secret, + algorithms=["HS256"], + audience=self._audience or None, + issuer=self._issuer or None, + options={"require": ["sub", "exp"]}, + ) + # RS256 + # ``__init__`` guarantees jwks_url is set for RS256; the assert is a + # defensive check for the type narrower (``PyJWKClient | None``). + if self._jwks_client is None: # pragma: no cover + raise InvalidTokenError(reason="jwks_client_missing") + signing_key = self._jwks_client.get_signing_key_from_jwt(token).key + return jwt.decode( + token, + signing_key, + algorithms=["RS256"], + audience=self._audience or None, + issuer=self._issuer or None, + options={"require": ["sub", "exp"]}, + ) + + +def _extract_roles(claims: dict[str, Any], path: str) -> tuple[str, ...]: + """Walk ``path`` (dotted) through ``claims`` and return a tuple of role strings. + + Returns an empty tuple when the path is missing, points to a non-list, + or contains non-string entries. Resilience over precision: a misconfigured + role path should never crash the request — it should just yield no roles. + """ + cursor: Any = claims + for segment in path.split("."): + if not isinstance(cursor, dict) or segment not in cursor: + return () + cursor = cursor[segment] + if not isinstance(cursor, list): + return () + return tuple(role for role in cursor if isinstance(role, str)) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/clock.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/clock.py new file mode 100644 index 0000000..299917f --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/clock.py @@ -0,0 +1,15 @@ +"""Production binding for the ``Clock`` abstraction.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from {{ cookiecutter.package_name }}.application.clock import Clock + + +class SystemClock(Clock): + """Clock backed by the operating system.""" + + def now(self) -> datetime: + """Return the current TZ-aware UTC instant.""" + return datetime.now(tz=UTC) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/config/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/config/__init__.py new file mode 100644 index 0000000..32140a6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/config/__init__.py @@ -0,0 +1,5 @@ +"""Application configuration.""" + +from {{ cookiecutter.package_name }}.infrastructure.config.settings import Settings, get_settings + +__all__ = ["Settings", "get_settings"] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/config/settings.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/config/settings.py new file mode 100644 index 0000000..c5ca7e2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/config/settings.py @@ -0,0 +1,139 @@ +"""Pydantic-Settings entry point. + +All configuration is read from environment variables (via .env in dev). +Required fields without defaults crash the boot — there is no silent fallback. +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Annotated, Any, Literal + +from pydantic import Field, SecretStr, field_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict + + +class Settings(BaseSettings): + """Strongly-typed application settings.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="forbid", + case_sensitive=False, + ) + + # ── App ────────────────────────────────────────────────────────── + app_name: str = "{{ cookiecutter.project_name }}" + log_format: Literal["console", "json"] = "console" + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" + service_name: str = "{{ cookiecutter.package_name }}" + + # ── Database ───────────────────────────────────────────────────── + db_url: SecretStr = Field(...) + db_pool_size: int = 10 + db_pool_max_overflow: int = 5 + db_pool_timeout: int = 30 + + # ── Observability ──────────────────────────────────────────────── +{%- if cookiecutter.include_otel == "yes" %} + otel_exporter_otlp_endpoint: str | None = None + otel_traces_sampler_arg: float = 1.0 +{%- endif %} + + # ── HTTP middleware ────────────────────────────────────────────── + request_max_body_bytes: int = 1_048_576 # 1 MiB + request_timeout_seconds: float = 30.0 + + # ── Outbox relay ───────────────────────────────────────────────── + # Port on which the relay process exposes /metrics (Prometheus). + # Scraped by a dedicated ServiceMonitor — see writing-a-helm-change. + relay_metrics_port: int = 9100 + + # ── Idempotency ────────────────────────────────────────────────── + # Bearer-style retry safety. Clients send Idempotency-Key: + # on retry-eligible writes; the middleware caches and replays the + # original response. See adding-idempotency-safety skill. + idempotency_enabled: bool = True + idempotency_methods: Annotated[list[str], NoDecode] = Field( + default_factory=lambda: ["POST", "PUT", "PATCH", "DELETE"] + ) + idempotency_ttl_seconds: int = 86_400 # 24h + + @field_validator("idempotency_methods", mode="before") + @classmethod + def _split_methods(cls, value: Any) -> Any: + """Accept comma-separated env-var override (POST,PUT,PATCH,DELETE).""" + if isinstance(value, str): + return [item.strip().upper() for item in value.split(",") if item.strip()] + return value + + # ── Auth ───────────────────────────────────────────────────────── + # Bearer-JWT verification. See ``infrastructure/auth/jwt_verifier.py`` + # and the ``adding-auth`` skill for the swap path to a real IdP. + auth_jwt_algorithm: Literal["HS256", "RS256"] = "HS256" + auth_jwt_secret: SecretStr | None = None + auth_jwt_jwks_url: str | None = None + auth_jwt_audience: str | None = None + auth_jwt_issuer: str | None = None + auth_jwt_roles_claim: str = "roles" + + # ── Swagger UI OAuth2 (optional) ───────────────────────────────── + # When ``swagger_oauth2_authorization_url`` and + # ``swagger_oauth2_token_url`` are BOTH set, the API exposes an + # ``OAuth2AuthorizationCodeBearer`` security scheme and Swagger UI + # renders a clickable "Authorize" button that redirects to the IdP + # for a real SSO login (no token paste required). + # + # When either URL is empty, the API falls back to ``HTTPBearer`` — + # Swagger UI still shows an "Authorize" button but the dev pastes a + # JWT manually (typical local-dev flow with ``AUTH_JWT_ALGORITHM=HS256`` + # and a shared secret). + # + # The Swagger UI client must be registered as a SEPARATE app on the + # IdP (Azure / Auth0 / Keycloak…) — see ``docs/swagger-oauth2.md`` + # for the Azure walkthrough. + swagger_oauth2_authorization_url: str | None = None + swagger_oauth2_token_url: str | None = None + swagger_oauth2_client_id: str | None = None + swagger_oauth2_scopes: Annotated[dict[str, str], NoDecode] = Field( + default_factory=lambda: { + "openid": "OpenID Connect baseline", + "email": "Email claim", + "profile": "Display name / picture claims", + } + ) + swagger_oauth2_pkce_enabled: bool = True + + @field_validator("swagger_oauth2_scopes", mode="before") + @classmethod + def _parse_scopes(cls, value: Any) -> Any: + """Accept ``openid,email,profile`` in env files as a shorthand.""" + if isinstance(value, str): + scopes = [item.strip() for item in value.split(",") if item.strip()] + return {scope: scope for scope in scopes} + return value + + # ── Security ───────────────────────────────────────────────────── + # ``NoDecode`` opts out of Pydantic's default JSON decoding so we can + # accept the much friendlier comma-separated format in .env files. + cors_allowed_origins: Annotated[list[str], NoDecode] = Field(default_factory=list) + + @field_validator("cors_allowed_origins", mode="before") + @classmethod + def _split_cors(cls, value: Any) -> Any: + """Allow comma-separated values in env files: 'a,b,c' -> ['a','b','c'].""" + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + return value + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Return the application settings singleton. + + Cached for the process lifetime. Tests should override via + ``app.dependency_overrides[get_settings]`` rather than calling + ``get_settings.cache_clear()``. + """ + return Settings() diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/container.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/container.py new file mode 100644 index 0000000..49f2cb3 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/container.py @@ -0,0 +1,149 @@ +"""Dependency injection container. + +Pure factory functions (no framework). FastAPI ``Depends`` wraps these in +``presentation/api/dependencies/``. CLI entry points and workers can call +these directly. + +Use cases returned by ``build_*_use_case`` go through ``_instrumented``, +which transparently adds an OTel span and structured logging around +``execute``. This is where the application/observability boundary lives: +application code never imports structlog or OpenTelemetry — those concerns +are bolted on from the outside. +""" + +from __future__ import annotations + +from typing import Any + +import structlog +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from {{ cookiecutter.package_name }}.application.auth.token_verifier import TokenVerifier +from {{ cookiecutter.package_name }}.application.clock import Clock +from {{ cookiecutter.package_name }}.application.event_bus import EventBus +from {{ cookiecutter.package_name }}.application.id_generator import IdGenerator +from {{ cookiecutter.package_name }}.application.random_source import RandomSource +from {{ cookiecutter.package_name }}.application.repositories.user import UserRepository +from {{ cookiecutter.package_name }}.application.use_cases.base import UseCase +from {{ cookiecutter.package_name }}.application.use_cases.ensure_user_exists import ( + EnsureUserExistsUseCase, +) +from {{ cookiecutter.package_name }}.domain.exceptions.base import DomainError +from {{ cookiecutter.package_name }}.infrastructure.auth.jwt_verifier import JwtTokenVerifier +from {{ cookiecutter.package_name }}.infrastructure.clock import SystemClock +from {{ cookiecutter.package_name }}.infrastructure.config.settings import Settings +from {{ cookiecutter.package_name }}.infrastructure.id_generator import Uuid4IdGenerator +from {{ cookiecutter.package_name }}.infrastructure.observability.tracing import traced +from {{ cookiecutter.package_name }}.infrastructure.outbox import SqlOutboxEventBus +from {{ cookiecutter.package_name }}.infrastructure.persistence.session import ( + build_engine, + build_session_factory, +) +from {{ cookiecutter.package_name }}.infrastructure.persistence.user_repository import SqlAlchemyUserRepository +from {{ cookiecutter.package_name }}.infrastructure.random_source import SystemRandomSource + + +def build_clock() -> Clock: + """Return the production ``Clock`` binding.""" + return SystemClock() + + +def build_id_generator() -> IdGenerator: + """Return the production ``IdGenerator`` binding.""" + return Uuid4IdGenerator() + + +def build_random_source() -> RandomSource: + """Return the production ``RandomSource`` binding.""" + return SystemRandomSource() + + +def build_database(settings: Settings) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: + """Build the engine and session factory from the application settings.""" + engine = build_engine(settings) + return engine, build_session_factory(engine) + + +def build_user_repository(session: AsyncSession) -> UserRepository: + """Build the SQLAlchemy-backed ``UserRepository`` for a given session.""" + return SqlAlchemyUserRepository(session) + + +def build_token_verifier(settings: Settings) -> TokenVerifier: + """Build the production ``TokenVerifier``. + + Wired to :class:`JwtTokenVerifier`; swap this body if the project moves + to a different scheme (mTLS, opaque tokens, …). + """ + secret = settings.auth_jwt_secret.get_secret_value() if settings.auth_jwt_secret else None + return JwtTokenVerifier( + algorithm=settings.auth_jwt_algorithm, + secret=secret, + jwks_url=settings.auth_jwt_jwks_url, + audience=settings.auth_jwt_audience, + issuer=settings.auth_jwt_issuer, + roles_claim=settings.auth_jwt_roles_claim, + ) + + +def build_event_bus(session: AsyncSession) -> EventBus: + """Build the production ``EventBus`` — writes events to the SQL outbox. + + Use cases that publish events receive this; the outbox row is committed + inside the request transaction (see ADR 0004), then forwarded to the + real broker by ``infrastructure/jobs/outbox_relay.py`` (see ADR 0005). + """ + return SqlOutboxEventBus(session) + + +def build_ensure_user_exists_use_case( + *, + users: UserRepository, + clock: Clock, + ids: IdGenerator, + events: EventBus, +) -> EnsureUserExistsUseCase: + """Construct ``EnsureUserExistsUseCase`` with cross-cutting tracing + logging.""" + use_case = EnsureUserExistsUseCase( + users=users, clock=clock, ids=ids, events=events + ) + return _instrumented(use_case, span_name="ensure_user_exists") + + +def _instrumented[U: UseCase[Any, Any]](use_case: U, *, span_name: str) -> U: + """Wrap a use case's ``execute`` with OTel tracing + structured logging. + + The wrapper: + - opens an OTel span named ``span_name`` (no-op when OTel is disabled) + - logs ``DomainError`` raises at WARNING with ``code`` and ``context`` + - logs unexpected exceptions at ERROR with full stack trace + - re-raises in all cases — the presentation handler still maps the + exception to an HTTP response. + + The wrapped use case is returned unchanged in identity; only its + ``execute`` attribute is rebound. Application code never sees this + wrapping — it imports nothing from infrastructure. + """ + original_execute = use_case.execute + logger = structlog.get_logger(use_case.__class__.__module__) + + @traced(span_name) + async def execute(input: Any) -> Any: + try: + return await original_execute(input) + except DomainError as exc: + logger.warning( + f"{span_name}.domain_error", + code=exc.code, + context=exc.context, + ) + raise + except Exception: + logger.exception(f"{span_name}.unexpected_error") + raise + + # method-assign: rebinding the bound method on the instance is the whole + # point of the wrapper — both type checkers (mypy, ty) flag it because + # the closure signature is not the unbound class method shape. + use_case.execute = execute # type: ignore[method-assign] # ty: ignore[invalid-assignment] + return use_case diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/id_generator.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/id_generator.py new file mode 100644 index 0000000..4fd65c1 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/id_generator.py @@ -0,0 +1,15 @@ +"""Production binding for the ``IdGenerator`` abstraction.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from {{ cookiecutter.package_name }}.application.id_generator import IdGenerator + + +class Uuid4IdGenerator(IdGenerator): + """``IdGenerator`` returning random UUID4 values.""" + + def new(self) -> UUID: + """Return a fresh UUID4.""" + return uuid4() diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/__init__.py new file mode 100644 index 0000000..ccb5446 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/__init__.py @@ -0,0 +1,7 @@ +"""Background jobs (workers, relays, schedulers). + +Each module here is a ``__main__``-runnable async script meant to live in a +separate container or process from the API. Pick a runner per project +(plain Python loop, ARQ, dramatiq, Celery) and document the choice; the +template ships only the polling-loop scaffold for the outbox relay. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/backoff.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/backoff.py new file mode 100644 index 0000000..707f6ea --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/backoff.py @@ -0,0 +1,38 @@ +"""Outbox relay retry policy — pure functions, no I/O. + +Schedule: + + after 1 failure → wait 1 second + after 2 failures → wait 5 seconds + after 3 failures → wait 30 seconds + after 4 failures → wait 2 minutes + after 5 failures → wait 10 minutes + after 6 failures → wait 30 minutes + after 7+ failures → wait 1 hour (capped) + after MAX_ATTEMPTS failures → row is poisoned, no further retries + +Total elapsed time before poison ≈ 1 + 5 + 30 + 120 + 600 + 1800 + 3600 x +(MAX_ATTEMPTS - 7) ≈ 3 hours at MAX_ATTEMPTS=10. Tune by editing this file. +""" + +from __future__ import annotations + +from datetime import timedelta + +MAX_ATTEMPTS = 10 +"""After this many failed attempts a row is marked ``poisoned`` and stops being polled.""" + +_DELAY_SCHEDULE_SECONDS: tuple[int, ...] = (1, 5, 30, 120, 600, 1800, 3600) +"""Indexed by ``(attempts - 1)``; values past the end use the last entry (cap).""" + + +def compute_next_attempt(attempts: int) -> timedelta: + """Return the delay before re-attempting a row that has failed ``attempts`` times. + + ``attempts`` is the post-failure count: 1 means "just failed for the first + time". Values <= 0 are treated as 1 — a row that has not failed at all has + no business calling this function, but the contract stays defensive. + """ + safe_attempts = max(attempts, 1) + index = min(safe_attempts - 1, len(_DELAY_SCHEDULE_SECONDS) - 1) + return timedelta(seconds=_DELAY_SCHEDULE_SECONDS[index]) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/event_handlers.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/event_handlers.py new file mode 100644 index 0000000..668f813 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/event_handlers.py @@ -0,0 +1,42 @@ +"""Concrete handlers for domain events. + +This module is imported once by the outbox relay at startup so the +``@handler`` decorators run and populate the registry in +:mod:`{{ cookiecutter.package_name }}.infrastructure.jobs.handlers`. + +Add a new handler by: + +1. Writing an async function ``async def my_handler(payload: dict) -> None:``. +2. Decorating it with ``@handler(".")``. +3. Restarting the relay (no other wiring needed). + +The shipped ``handle_user_created`` is the reference example — a structured +log line per event. Replace or extend it with real side-effects (welcome +email, cache warm-up, projection update …). +""" + +from __future__ import annotations + +from typing import Any + +import structlog + +from {{ cookiecutter.package_name }}.infrastructure.jobs.handlers import handler + +logger = structlog.get_logger(__name__) + + +@handler("user.created") +async def handle_user_created(payload: dict[str, Any]) -> None: + """Log a structured line for each new user. + + Reference handler — replace with the real side-effect (welcome email, + analytics enrichment, downstream projection …) when the project needs + one. Must be idempotent: at-least-once delivery means this handler may + run multiple times for the same ``user_id``. + """ + logger.info( + "event.user_created", + user_id=payload.get("user_id"), + email=payload.get("email"), + ) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/handlers.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/handlers.py new file mode 100644 index 0000000..1d6ac4a --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/handlers.py @@ -0,0 +1,69 @@ +"""In-process event handler registry. + +This is the consumer side of the outbox pattern (ADR 0005). The current SOMA +template runs handlers in the SAME process as the outbox relay — there is no +external broker. The swap point is :func:`dispatch`: replace its body with a +real broker publish call (Kafka, Pub/Sub, SQS, …) when the project goes +multi-service. + +Handlers are registered with the :func:`handler` decorator at module import +time. The relay worker imports :mod:`event_handlers` once at startup so the +decorators run and populate the registry; from then on, every drained outbox +row is forwarded to all matching handlers in registration order. + +Concurrency posture (in-process variant): + +* Handlers run sequentially per event. If a handler raises, the row stays + unpublished and the relay retries on the next tick (see :func:`_drain_once`). +* Replicating the relay across pods will run handlers on every replica — + either keep a single replica, or make handlers idempotent. +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Awaitable, Callable +from typing import Any + +Handler = Callable[[dict[str, Any]], Awaitable[None]] + +_HANDLERS: dict[str, list[Handler]] = defaultdict(list) + + +def handler(event_name: str) -> Callable[[Handler], Handler]: + """Register ``fn`` as a handler for ``event_name``. + + Multiple handlers per event are allowed; they run in registration order. + The handler MUST be idempotent: at-least-once delivery is the guarantee + the outbox gives, so a retry after a relay crash will re-invoke the + handler on the same payload. + """ + + def decorator(fn: Handler) -> Handler: + _HANDLERS[event_name].append(fn) + return fn + + return decorator + + +async def dispatch(event_name: str, payload: dict[str, Any]) -> None: + """Run all handlers registered for ``event_name`` against ``payload``. + + Raises the first handler exception encountered. The outbox relay catches + it, logs it, and leaves the row unpublished so the next tick retries. + + This is the function to replace when wiring a real broker — its body + becomes ``await broker.publish(event_name, payload)`` and the registry + is removed (or kept for local development). + """ + for fn in _HANDLERS[event_name]: + await fn(payload) + + +def registered_event_names() -> list[str]: + """Return the event names that currently have at least one handler. + + Exposed for diagnostics — startup banners, readiness checks, tests that + assert a handler was wired. + """ + return [name for name, fns in _HANDLERS.items() if fns] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/outbox_metrics.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/outbox_metrics.py new file mode 100644 index 0000000..d3fbf48 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/outbox_metrics.py @@ -0,0 +1,67 @@ +"""Prometheus metrics for the outbox relay. + +Exposed by the relay process via ``prometheus_client.start_http_server`` — +see ``infrastructure/jobs/outbox_relay.run`` for the wiring. The gauges +are refreshed at the end of every drain tick by :func:`refresh_gauges`. + +Alert thresholds (suggested defaults — wire in a PrometheusRule): + +* ``outbox_events_pending > 1000`` for 5 minutes → producer-side backlog +* ``outbox_events_poisoned > 0`` → manual ops triage required +* ``outbox_oldest_pending_age_seconds > 300`` → relay or downstream lag +* ``rate(outbox_handler_failures_total[5m]) > 0.1`` → handler regression +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from prometheus_client import Counter, Gauge +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.outbox_event import OutboxEventModel + +outbox_events_pending = Gauge( + "outbox_events_pending", + "Number of outbox rows awaiting publication (status='pending').", +) +outbox_events_poisoned = Gauge( + "outbox_events_poisoned", + "Number of outbox rows that exhausted their retries (status='poisoned').", +) +outbox_oldest_pending_age_seconds = Gauge( + "outbox_oldest_pending_age_seconds", + "Age of the oldest pending outbox row in seconds — the primary lag signal.", +) +outbox_handler_failures_total = Counter( + "outbox_handler_failures_total", + "Cumulative count of handler failures, labelled by event_name.", + ["event_name"], +) + + +async def refresh_gauges(session: AsyncSession) -> None: + """Re-read the outbox stats and update the three gauges. + + Called once per drain tick. Three quick aggregate queries — cheap on + indexed columns, fine to run every second. + """ + pending_count = await session.scalar( + select(func.count()).where(OutboxEventModel.status == "pending") + ) + outbox_events_pending.set(int(pending_count or 0)) + + poisoned_count = await session.scalar( + select(func.count()).where(OutboxEventModel.status == "poisoned") + ) + outbox_events_poisoned.set(int(poisoned_count or 0)) + + oldest_created_at = await session.scalar( + select(func.min(OutboxEventModel.created_at)).where(OutboxEventModel.status == "pending") + ) + if oldest_created_at is None: + outbox_oldest_pending_age_seconds.set(0) + else: + age = (datetime.now(tz=UTC) - oldest_created_at).total_seconds() + outbox_oldest_pending_age_seconds.set(max(age, 0)) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/outbox_relay.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/outbox_relay.py new file mode 100644 index 0000000..6dbc1d3 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/jobs/outbox_relay.py @@ -0,0 +1,170 @@ +"""Outbox relay — forwards pending outbox rows to in-process handlers. + +Run as a separate process or container alongside the API: + + uv run python -m {{ cookiecutter.package_name }}.infrastructure.jobs.outbox_relay + +The relay dispatches each due ``pending`` row to the handler registry in +:mod:`{{ cookiecutter.package_name }}.infrastructure.jobs.handlers`. The whole +template runs handlers IN-PROCESS: no external broker is wired by default +(ADR 0005). When the project goes multi-service, replace the body of +``handlers.dispatch`` with a real broker publish call — the relay does not +change. + +Guarantees: + +* **At-least-once**: a row transitions to ``status='published'`` only after + every handler returns successfully. A handler exception increments + ``attempts`` and schedules a retry per :mod:`backoff`. +* **Dead-letter**: after ``MAX_ATTEMPTS`` failures the row transitions to + ``status='poisoned'`` and is no longer polled — the + ``outbox_events_poisoned`` gauge fires the alert. +* **Multi-replica safe**: ``SELECT ... FOR UPDATE SKIP LOCKED`` lets + multiple relay replicas process disjoint batches without dedup. +* **Idempotency on the handler side**: handlers MUST tolerate replay; an + earlier successful side-effect can re-occur on a relay crash before + ``commit()`` set the status. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import signal +from datetime import UTC, datetime +from typing import Any + +import structlog +from prometheus_client import start_http_server +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from {{ cookiecutter.package_name }}.infrastructure.config.settings import get_settings +from {{ cookiecutter.package_name }}.infrastructure.container import build_database +from {{ cookiecutter.package_name }}.infrastructure.jobs import ( + event_handlers, # noqa: F401 — import side-effect: register decorators +) +from {{ cookiecutter.package_name }}.infrastructure.jobs.backoff import MAX_ATTEMPTS, compute_next_attempt +from {{ cookiecutter.package_name }}.infrastructure.jobs.handlers import dispatch +from {{ cookiecutter.package_name }}.infrastructure.jobs.outbox_metrics import ( + outbox_handler_failures_total, + refresh_gauges, +) +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.outbox_event import OutboxEventModel + +logger = structlog.get_logger(__name__) + +POLL_INTERVAL_SECONDS = 1.0 +BATCH_SIZE = 100 +_LAST_ERROR_MAX_LENGTH = 500 + + +async def _publish_event(event_name: str, payload: dict[str, Any]) -> None: + """Forward one event to every handler registered for ``event_name``. + + Raises if any handler raises. This function is the swap point for wiring + a real broker: replace ``await dispatch(...)`` with + ``await broker.publish(...)``. + """ + await dispatch(event_name, payload) + + +async def _drain_once(session: AsyncSession) -> int: + """Process one batch. Returns the number of events successfully published. + + Locks the picked rows via ``SELECT FOR UPDATE SKIP LOCKED`` so concurrent + relays do not double-process. On Postgres the locks are released by the + ``commit()`` at the end. On SQLite (used only by unit tests; integration + runs on real Postgres) the clause is silently ignored — single-writer + semantics suffice in that case. + """ + now = datetime.now(tz=UTC) + pending = await session.execute( + select(OutboxEventModel) + .where( + OutboxEventModel.status == "pending", + or_( + OutboxEventModel.next_attempt_at.is_(None), + OutboxEventModel.next_attempt_at <= now, + ), + ) + .order_by(OutboxEventModel.created_at) + .limit(BATCH_SIZE) + .with_for_update(skip_locked=True) + ) + rows = list(pending.scalars()) + published = 0 + for row in rows: + try: + await _publish_event(row.event_name, row.payload) + except Exception as exc: + _record_failure(row, exc, now=datetime.now(tz=UTC)) + outbox_handler_failures_total.labels(event_name=row.event_name).inc() + logger.warning( + "outbox.publish_failed", + event_id=str(row.id), + event_name=row.event_name, + attempts=row.attempts, + status=row.status, + ) + continue + _record_success(row, now=datetime.now(tz=UTC)) + published += 1 + + await session.commit() + await refresh_gauges(session) + return published + + +def _record_success(row: OutboxEventModel, *, now: datetime) -> None: + row.status = "published" + row.published_at = now + row.last_attempt_at = now + + +def _record_failure(row: OutboxEventModel, exc: BaseException, *, now: datetime) -> None: + row.attempts += 1 + row.last_attempt_at = now + row.last_error = repr(exc)[:_LAST_ERROR_MAX_LENGTH] + if row.attempts >= MAX_ATTEMPTS: + row.status = "poisoned" + row.next_attempt_at = None + else: + row.next_attempt_at = now + compute_next_attempt(row.attempts) + + +async def run() -> None: + """Polling loop. Stops cleanly on SIGTERM/SIGINT.""" + stop = asyncio.Event() + + def _handle_signal() -> None: + logger.info("outbox.shutdown_signal") + stop.set() + + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, _handle_signal) + + settings = get_settings() + engine, factory = build_database(settings) + start_http_server(settings.relay_metrics_port) + logger.info( + "outbox.relay_started", + poll_interval_seconds=POLL_INTERVAL_SECONDS, + metrics_port=settings.relay_metrics_port, + ) + + try: + while not stop.is_set(): + async with factory() as session: + published = await _drain_once(session) + if published == 0: + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(stop.wait(), timeout=POLL_INTERVAL_SECONDS) + finally: + await engine.dispose() + logger.info("outbox.relay_stopped") + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/observability/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/observability/__init__.py new file mode 100644 index 0000000..b9a5732 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/observability/__init__.py @@ -0,0 +1,14 @@ +"""Observability primitives: structured logging, tracing, metrics setup.""" + +from {{ cookiecutter.package_name }}.infrastructure.observability.logging import configure_logging +{%- if cookiecutter.include_otel == "yes" %} +from {{ cookiecutter.package_name }}.infrastructure.observability.tracing import configure_tracing, traced +{%- endif %} + +__all__ = [ + "configure_logging", +{%- if cookiecutter.include_otel == "yes" %} + "configure_tracing", + "traced", +{%- endif %} +] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/observability/logging.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/observability/logging.py new file mode 100644 index 0000000..4653eaf --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/observability/logging.py @@ -0,0 +1,57 @@ +"""structlog configuration with secret redaction and OTel correlation.""" + +from __future__ import annotations + +import logging +import sys +from typing import Any + +import structlog +from structlog.types import EventDict, Processor + +REDACTED = "***" +SENSITIVE_KEY_FRAGMENTS = ("password", "token", "authorization", "secret", "api_key") + + +def _redact_secrets(_: Any, __: str, event_dict: EventDict) -> EventDict: + """Replace values whose key contains a sensitive fragment with ``***``. + + Conservative by design: any key matching a fragment is redacted, even when + the value is empty. Tests cover the redaction set. + """ + for key in list(event_dict.keys()): + lower = key.lower() + if any(fragment in lower for fragment in SENSITIVE_KEY_FRAGMENTS): + event_dict[key] = REDACTED + return event_dict + + +def configure_logging(*, log_format: str, log_level: str) -> None: + """Configure structlog and the stdlib logging facade. + + Args: + log_format: ``console`` for dev (colored, human-readable) or + ``json`` for prod (one JSON line per event, ingestable). + log_level: minimum severity (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR``). + """ + timestamper = structlog.processors.TimeStamper(fmt="iso", utc=True) + + shared_processors: list[Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + timestamper, + _redact_secrets, + ] + + renderer: Processor + if log_format == "json": + renderer = structlog.processors.JSONRenderer() + else: + renderer = structlog.dev.ConsoleRenderer(colors=sys.stdout.isatty()) + + structlog.configure( + processors=[*shared_processors, renderer], + wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, log_level.upper())), + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/observability/tracing.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/observability/tracing.py new file mode 100644 index 0000000..37cc70e --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/observability/tracing.py @@ -0,0 +1,96 @@ +{% if cookiecutter.include_otel == "yes" -%} +"""OpenTelemetry tracing setup with auto-switching exporter. + +When ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set the SDK exports spans via gRPC +OTLP. Otherwise spans are written to stdout (zero-config dev). The +``@traced`` decorator opens a custom span around any callable. +""" + +from __future__ import annotations + +import functools +from collections.abc import Awaitable, Callable +from typing import ParamSpec, TypeVar + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter +from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased + +from {{ cookiecutter.package_name }}.infrastructure.config.settings import Settings + +P = ParamSpec("P") +R = TypeVar("R") + + +def configure_tracing(settings: Settings) -> None: + """Initialise the global tracer provider based on settings. + + Idempotent: subsequent calls replace the active provider. + """ + resource = Resource.create({"service.name": settings.service_name}) + sampler = ParentBased(TraceIdRatioBased(settings.otel_traces_sampler_arg)) + provider = TracerProvider(resource=resource, sampler=sampler) + + exporter: OTLPSpanExporter | ConsoleSpanExporter + if settings.otel_exporter_otlp_endpoint: + exporter = OTLPSpanExporter(endpoint=settings.otel_exporter_otlp_endpoint) + else: + exporter = ConsoleSpanExporter() + + provider.add_span_processor(BatchSpanProcessor(exporter)) + trace.set_tracer_provider(provider) + + +def traced(span_name: str) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]: + """Decorate an async callable with a custom OTel span. + + Usage: + @traced("ensure_user_exists") + async def execute(self, input: EnsureUserExistsInput) -> EnsureUserExistsOutput: ... + """ + + def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + tracer = trace.get_tracer(func.__module__) + + @functools.wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + with tracer.start_as_current_span(span_name): + return await func(*args, **kwargs) + + return wrapper + + return decorator +{%- else -%} +"""OpenTelemetry tracing is disabled in this generated project. + +Re-enable by regenerating with ``include_otel=yes`` or by adding +``opentelemetry-api`` + ``opentelemetry-sdk`` to ``pyproject.toml`` and +restoring the implementation from the template repository. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import ParamSpec, TypeVar + +from {{ cookiecutter.package_name }}.infrastructure.config.settings import Settings + +P = ParamSpec("P") +R = TypeVar("R") + + +def configure_tracing(_settings: Settings) -> None: + """No-op when OTel is not included.""" + + +def traced(_span_name: str) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]: + """Identity decorator when OTel is not included.""" + + def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + return func + + return decorator +{%- endif %} diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/outbox.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/outbox.py new file mode 100644 index 0000000..50b755b --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/outbox.py @@ -0,0 +1,49 @@ +"""SQL-backed outbox event bus. + +Persists events to the ``outbox_events`` table inside the request +transaction. A relay worker (see ``infrastructure/jobs/outbox_relay.py``) +forwards rows to the configured broker out-of-band, providing at-least-once +delivery semantics aligned with the entity write. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from {{ cookiecutter.package_name }}.application.event_bus import EventBus +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.outbox_event import OutboxEventModel + + +class SqlOutboxEventBus(EventBus): + """``EventBus`` that enqueues events into the SQL outbox. + + The bus shares the request session, so the row hits the DB inside the + same transaction as the entity write — no orphan events, no events + lost when the write fails. + """ + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def publish(self, event_name: str, payload: dict[str, Any]) -> None: + """Append an outbox row to the current transaction.""" + self._session.add( + OutboxEventModel( + id=uuid4(), + event_name=event_name, + payload=payload, + created_at=datetime.now(tz=UTC), + ) + ) + + +class NullEventBus(EventBus): + """Default ``EventBus`` for projects that do not publish events yet.""" + + async def publish(self, event_name: str, payload: dict[str, Any]) -> None: # noqa: ARG002 + """Drop the event. The signature stays compatible for future swap.""" + return diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/__init__.py new file mode 100644 index 0000000..232fcfc --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/__init__.py @@ -0,0 +1,7 @@ +"""Persistence concretes (SQLAlchemy 2.0 async). + +ORM models live under ``models/`` and are NEVER exposed outside this package. +Repositories implement the abstractions declared in +``application/repositories/`` and translate between domain entities and ORM +models in private ``_to_entity`` / ``_to_model`` helpers. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/__init__.py new file mode 100644 index 0000000..0ed1269 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/__init__.py @@ -0,0 +1,24 @@ +"""SQLAlchemy ORM models. + +Models live ONLY in this package. They are mapped to domain entities by +repositories and never exposed outward. + +EVERY model module must be imported here so that ``Base.metadata`` knows +about it when Alembic autogenerates a migration. Without this, +``alembic revision --autogenerate`` would think the missing model means +"the table should be dropped" — producing a destructive migration. Add +new models below as the project grows. +""" + +from {{ cookiecutter.package_name }}.infrastructure.persistence.models import ( # noqa: F401 + idempotency_record as _idempotency_record, +) +from {{ cookiecutter.package_name }}.infrastructure.persistence.models import ( # noqa: F401 + outbox_event as _outbox_event, +) +from {{ cookiecutter.package_name }}.infrastructure.persistence.models import ( # noqa: F401 + user as _user, +) +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.base import Base + +__all__ = ["Base"] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/base.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/base.py new file mode 100644 index 0000000..befe12a --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/base.py @@ -0,0 +1,9 @@ +"""Declarative base for all ORM models.""" + +from __future__ import annotations + +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Common SQLAlchemy declarative base.""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/idempotency_record.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/idempotency_record.py new file mode 100644 index 0000000..3312685 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/idempotency_record.py @@ -0,0 +1,51 @@ +"""SQLAlchemy ORM model for ``idempotency_records``. + +Backs the IdempotencyMiddleware. One row per client-supplied +``Idempotency-Key`` value; rows are short-lived (default 24h) and cleaned +up lazily at lookup time. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal +from uuid import UUID # noqa: F401 # kept for symmetry with other models + +from sqlalchemy import JSON, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.base import Base + +IdempotencyStatus = Literal["in_progress", "completed"] + + +class IdempotencyRecord(Base): + """One persisted reply for a client-supplied ``Idempotency-Key``. + + Lifecycle: + + * inserted with ``status='in_progress'`` when the first request reaches + the middleware; on PK conflict, retries either return the cached + reply (``status='completed'``) or 409 (still in flight). + * after the handler returns successfully, the row is updated to + ``status='completed'`` with ``response_status / response_body / + response_headers`` populated. On handler exception the row is left + in-progress and times out via ``expires_at``. + + The row stores the original ``method`` and ``path`` so a retry that + targets a different endpoint with the same key returns 422 instead of + a stale response. + """ + + __tablename__ = "idempotency_records" + + key: Mapped[str] = mapped_column(String(255), primary_key=True) + status: Mapped[IdempotencyStatus] = mapped_column(String(16), nullable=False) + method: Mapped[str] = mapped_column(String(16), nullable=False) + path: Mapped[str] = mapped_column(String(2048), nullable=False) + response_status: Mapped[int | None] = mapped_column(Integer, nullable=True) + response_body: Mapped[Any | None] = mapped_column(JSON, nullable=True) + response_headers: Mapped[dict[str, str] | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/outbox_event.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/outbox_event.py new file mode 100644 index 0000000..84b9bc8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/outbox_event.py @@ -0,0 +1,69 @@ +"""SQLAlchemy ORM model for the outbox table.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal +from uuid import UUID + +from sqlalchemy import JSON, DateTime, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.base import Base + +OutboxStatus = Literal["pending", "published", "poisoned"] + + +class OutboxEventModel(Base): + """``outbox_events`` — domain events queued for asynchronous publication. + + Rows are inserted by the use case in the same transaction as the entity + write (see ADR 0005). A separate relay process polls + ``status='pending' AND next_attempt_at <= now()`` and dispatches rows + through the in-process handler registry (see + :mod:`{{ cookiecutter.package_name }}.infrastructure.jobs.handlers`). + + Lifecycle: + + * inserted with ``status='pending'``, ``attempts=0``, ``next_attempt_at=NULL`` + * on dispatch success → ``status='published'``, ``published_at=now()`` + * on dispatch failure → ``attempts++``, ``last_attempt_at=now()``, + ``next_attempt_at=now()+backoff(attempts)``, ``last_error=`` + * after MAX_ATTEMPTS failures → ``status='poisoned'`` (no longer polled). + """ + + __tablename__ = "outbox_events" + + id: Mapped[UUID] = mapped_column(primary_key=True) + event_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + # Retry / dead-letter state — see infrastructure/jobs/backoff.py + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + # No standalone ``index=True`` on ``status`` — Postgres can satisfy + # ``WHERE status = 'pending'`` from the composite index below (status is + # the leading column). Avoids autogenerate drift between a single-column + # ``ix_outbox_events_status`` and the composite. + status: Mapped[OutboxStatus] = mapped_column( + String(16), + nullable=False, + default="pending", + server_default="pending", + ) + + # Composite index matching the relay's polling ``WHERE`` clause: + # ``status='pending' AND next_attempt_at <= now()``. Declared explicitly + # so ``alembic revision --autogenerate`` reports no drift against the + # 0003 migration that originally created it. + __table_args__ = ( + Index( + "ix_outbox_events_status_next_attempt", + "status", + "next_attempt_at", + ), + ) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/user.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/user.py new file mode 100644 index 0000000..47e04f4 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/models/user.py @@ -0,0 +1,25 @@ +"""SQLAlchemy ORM model for ``User``.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.base import Base + + +class UserModel(Base): + """``users`` table mapping. Never exposed outside ``infrastructure/persistence/``.""" + + __tablename__ = "users" + + id: Mapped[UUID] = mapped_column(primary_key=True) + # IdP-issued ``sub`` claim — primary lookup key on every authenticated + # request (see ``EnsureUserExistsUseCase``). UNIQUE + indexed. + subject: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/session.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/session.py new file mode 100644 index 0000000..3b3d129 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/session.py @@ -0,0 +1,35 @@ +"""Async SQLAlchemy engine and session factory. + +The engine is configured from ``Settings`` and lives for the process lifetime. +Sessions are created per-request and closed by the FastAPI dependency. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +from {{ cookiecutter.package_name }}.infrastructure.config.settings import Settings + + +def build_engine(settings: Settings) -> AsyncEngine: + """Create the async engine from the application settings.""" + return create_async_engine( + settings.db_url.get_secret_value(), + pool_size=settings.db_pool_size, + max_overflow=settings.db_pool_max_overflow, + pool_timeout=settings.db_pool_timeout, + pool_pre_ping=True, + ) + + +def build_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + """Build a session factory bound to the engine.""" + return async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + +async def session_scope(factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[AsyncSession]: + """Open an async session and ensure it is closed on exit.""" + async with factory() as session: + yield session diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/user_repository.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/user_repository.py new file mode 100644 index 0000000..4b5f624 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/persistence/user_repository.py @@ -0,0 +1,68 @@ +"""SQLAlchemy implementation of ``UserRepository``.""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from {{ cookiecutter.package_name }}.application.repositories.user import UserRepository +from {{ cookiecutter.package_name }}.domain.entities.user import User +from {{ cookiecutter.package_name }}.domain.exceptions.user import UserAlreadyExistsError +from {{ cookiecutter.package_name }}.domain.value_objects.email import Email +from {{ cookiecutter.package_name }}.domain.value_objects.user_id import UserId +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.user import UserModel + + +class SqlAlchemyUserRepository(UserRepository): + """``UserRepository`` backed by SQLAlchemy 2.0 async.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def add(self, user: User) -> None: + """Persist ``user``, raising ``UserAlreadyExistsError`` on uniqueness violation.""" + self._session.add(self._to_model(user)) + try: + await self._session.flush() + except IntegrityError as exc: + raise UserAlreadyExistsError( + subject=user.subject, email=str(user.email) + ) from exc + + async def find_by_subject(self, subject: str) -> User | None: + """Return the user matching ``subject`` or ``None``.""" + stmt = select(UserModel).where(UserModel.subject == subject) + model = (await self._session.execute(stmt)).scalar_one_or_none() + return self._to_entity(model) if model is not None else None + + async def find_by_email(self, email: Email) -> User | None: + """Return the user matching ``email`` or ``None``.""" + stmt = select(UserModel).where(UserModel.email == str(email)) + model = (await self._session.execute(stmt)).scalar_one_or_none() + return self._to_entity(model) if model is not None else None + + async def find_by_id(self, id: UserId) -> User | None: + """Return the user with the given identifier or ``None``.""" + model = await self._session.get(UserModel, id.value) + return self._to_entity(model) if model is not None else None + + @staticmethod + def _to_model(user: User) -> UserModel: + return UserModel( + id=user.id.value, + subject=user.subject, + email=str(user.email), + name=user.name, + created_at=user.created_at, + ) + + @staticmethod + def _to_entity(model: UserModel) -> User: + return User( + id=UserId(model.id), + subject=model.subject, + email=Email(model.email), + name=model.name, + created_at=model.created_at, + ) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/random_source.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/random_source.py new file mode 100644 index 0000000..6ca1c3b --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/infrastructure/random_source.py @@ -0,0 +1,22 @@ +"""Production binding for the ``RandomSource`` abstraction.""" + +from __future__ import annotations + +import secrets + +from {{ cookiecutter.package_name }}.application.random_source import RandomSource + + +class SystemRandomSource(RandomSource): + """Cryptographically strong random source backed by ``secrets``.""" + + def next_int(self, low: int, high: int) -> int: + """Return a uniformly distributed integer in ``[low, high]``.""" + if low > high: + raise ValueError(f"low ({low}) must be <= high ({high})") + return low + secrets.randbelow(high - low + 1) + + def next_float(self) -> float: + """Return a uniformly distributed float in ``[0.0, 1.0)``.""" + # 53 bits of randomness mapped to [0.0, 1.0) + return secrets.randbits(53) / (1 << 53) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/__init__.py new file mode 100644 index 0000000..6a13c81 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/__init__.py @@ -0,0 +1,7 @@ +"""Presentation layer. + +Delivery mechanisms: FastAPI routes, Pydantic schemas, error handlers, +middlewares, optional CLI. Imports `application` (and `domain` for types). +Never imports `infrastructure` directly — concretes are injected via the +DI container exposed through FastAPI `Depends`. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/app.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/app.py new file mode 100644 index 0000000..c23ffab --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/app.py @@ -0,0 +1,149 @@ +"""FastAPI application factory. + +Builds the app from settings, attaches observability, registers middlewares +and error handlers, and exposes the database session factory through +``app.state``. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import structlog +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse +{%- if cookiecutter.include_otel == "yes" %} +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.logging import LoggingInstrumentor +from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor +{%- endif %} +from prometheus_fastapi_instrumentator import Instrumentator + +from {{ cookiecutter.package_name }}.infrastructure.config.settings import Settings, get_settings +from {{ cookiecutter.package_name }}.infrastructure.container import build_database +from {{ cookiecutter.package_name }}.infrastructure.observability import ( + configure_logging, +{%- if cookiecutter.include_otel == "yes" %} + configure_tracing, +{%- endif %} +) +from {{ cookiecutter.package_name }}.presentation.api.error_handlers import register_error_handlers +from {{ cookiecutter.package_name }}.presentation.api.health import router as health_router +from {{ cookiecutter.package_name }}.presentation.api.middleware import ( + AccessLogMiddleware, + BodySizeLimitMiddleware, + IdempotencyMiddleware, + RequestIdMiddleware, + RequestTimeoutMiddleware, + SecurityHeadersMiddleware, +) +from {{ cookiecutter.package_name }}.presentation.api.v1 import auth as auth_v1 +from {{ cookiecutter.package_name }}.presentation.api.v1 import users as users_v1 + +logger = structlog.get_logger(__name__) + + +def create_app(settings: Settings | None = None) -> FastAPI: + """Build and configure the FastAPI application.""" + settings = settings or get_settings() + configure_logging(log_format=settings.log_format, log_level=settings.log_level) +{%- if cookiecutter.include_otel == "yes" %} + configure_tracing(settings) + # Idempotent guard: ``create_app`` may run multiple times in the same + # process (e.g. test fixtures); instrumenting twice raises warnings. + _logging_instrumentor = LoggingInstrumentor() + if not getattr(_logging_instrumentor, "_is_instrumented_by_opentelemetry", False): + _logging_instrumentor.instrument(set_logging_format=False) +{%- endif %} + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + engine, factory = build_database(settings) + app.state.engine = engine + app.state.session_factory = factory +{%- if cookiecutter.include_otel == "yes" %} + SQLAlchemyInstrumentor().instrument(engine=engine.sync_engine) +{%- endif %} + logger.info("app.startup", service=settings.service_name) + try: + yield + finally: + await engine.dispose() + logger.info("app.shutdown") + + # Swagger UI OAuth2 init — when the IdP endpoints are configured, the + # generated docs at /docs render an interactive "Authorize" flow that + # redirects to the IdP (Authorization Code + PKCE). When unconfigured, + # Swagger falls back to the HTTPBearer paste-once UX (see + # ``presentation/api/dependencies/auth.py::_build_security_scheme``). + swagger_ui_init_oauth: dict[str, object] | None = ( + { + "clientId": settings.swagger_oauth2_client_id, + "usePkceWithAuthorizationCodeGrant": settings.swagger_oauth2_pkce_enabled, + "scopes": " ".join(settings.swagger_oauth2_scopes), + } + if settings.swagger_oauth2_client_id + else None + ) + + app = FastAPI( + title=settings.app_name, + version="0.1.0", + lifespan=lifespan, + swagger_ui_init_oauth=swagger_ui_init_oauth, + ) + + # Middleware order: the LAST ``add_middleware`` becomes the OUTERMOST + # wrapper. We want, from outermost to innermost: + # RequestId -> AccessLog -> BodySizeLimit -> RequestTimeout -> CORS -> + # Idempotency -> SecurityHeaders -> handler + # Idempotency sits close to the handler so timing/body-limit middleware + # don't get bypassed on a cached replay. + app.add_middleware(SecurityHeadersMiddleware) + if settings.idempotency_enabled: + app.add_middleware( + IdempotencyMiddleware, + methods=frozenset(settings.idempotency_methods), + ttl_seconds=settings.idempotency_ttl_seconds, + ) + if settings.cors_allowed_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_allowed_origins, + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, + ) + app.add_middleware(RequestTimeoutMiddleware, timeout_seconds=settings.request_timeout_seconds) + app.add_middleware(BodySizeLimitMiddleware, max_bytes=settings.request_max_body_bytes) + app.add_middleware(AccessLogMiddleware) + app.add_middleware(RequestIdMiddleware) + + register_error_handlers(app) + +{%- if cookiecutter.include_otel == "yes" %} + FastAPIInstrumentor().instrument_app(app) +{%- endif %} + + # Prometheus metrics endpoint. Always-on by design: the Helm chart's + # ServiceMonitor (gated by metrics.enabled) decides whether the cluster + # actually scrapes this endpoint. Excluded paths avoid /metrics scraping + # itself (recursive noise) and the high-volume health probes. + Instrumentator( + excluded_handlers=["/metrics", "/health/.*"], + should_group_status_codes=False, + ).instrument(app).expose(app, include_in_schema=False, endpoint="/metrics") + + @app.get("/", include_in_schema=False) + async def _root_to_docs() -> RedirectResponse: + return RedirectResponse(url="/docs") + + app.include_router(health_router) + app.include_router(auth_v1.router) + app.include_router(users_v1.router) + return app + + +app = create_app() diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/__init__.py new file mode 100644 index 0000000..64552ce --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/__init__.py @@ -0,0 +1,22 @@ +"""FastAPI ``Depends`` wiring around the infrastructure container. + +Each dependency is a thin wrapper that pulls the required collaborator from +``infrastructure/container.py``. Tests override these via +``app.dependency_overrides`` to inject fakes. +""" + +from {{ cookiecutter.package_name }}.presentation.api.dependencies.common import ( + get_clock, + get_id_generator, + get_random_source, + get_session, + get_settings_dependency, +) + +__all__ = [ + "get_clock", + "get_id_generator", + "get_random_source", + "get_session", + "get_settings_dependency", +] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/auth.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/auth.py new file mode 100644 index 0000000..3cdadf6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/auth.py @@ -0,0 +1,166 @@ +"""Authentication / authorization FastAPI dependencies. + +The chain is: + + Authorization: Bearer + → get_bearer_token (extract; raise MissingTokenError on absent) + → get_current_user (verify with TokenVerifier; raise InvalidTokenError) + → require_roles(...) (assert role membership; raise InsufficientPermissionsError) + +Routes opt in to authentication by declaring ``Depends(get_current_user)`` +in their handler signature. Public routes do nothing — they stay public. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from fastapi import Depends, Request, Security +from fastapi.security import ( + HTTPAuthorizationCredentials, + HTTPBearer, + OAuth2AuthorizationCodeBearer, +) +from fastapi.security.base import SecurityBase + +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser +from {{ cookiecutter.package_name }}.application.auth.token_verifier import TokenVerifier +from {{ cookiecutter.package_name }}.domain.exceptions.auth import ( + InsufficientPermissionsError, + MissingTokenError, +) +from {{ cookiecutter.package_name }}.infrastructure.config.settings import Settings, get_settings +from {{ cookiecutter.package_name }}.infrastructure.container import build_token_verifier + + +def _build_security_scheme(settings: Settings) -> SecurityBase: + """Pick the OpenAPI security scheme based on Settings. + + When the OAuth2 endpoints are configured (production with a real IdP + app registration for Swagger UI), the API publishes + ``OAuth2AuthorizationCodeBearer`` and Swagger UI renders a clickable + "Authorize" button that triggers the full IdP redirect flow. + + Otherwise the API falls back to ``HTTPBearer`` — Swagger UI still + shows an "Authorize" button but the dev pastes a JWT manually + (typical local-dev flow with ``AUTH_JWT_ALGORITHM=HS256``). + + ``auto_error=False`` in both cases keeps our ``MissingTokenError`` + path the single source of truth for missing/malformed headers + (no FastAPI-default ``HTTPException`` leaking through). + """ + if settings.swagger_oauth2_authorization_url and settings.swagger_oauth2_token_url: + return OAuth2AuthorizationCodeBearer( + authorizationUrl=settings.swagger_oauth2_authorization_url, + tokenUrl=settings.swagger_oauth2_token_url, + scopes=settings.swagger_oauth2_scopes, + description=( + "Click Authorize to be redirected to your IdP for a real " + "SSO login (Authorization Code + PKCE)." + ), + auto_error=False, + ) + return HTTPBearer( + bearerFormat="JWT", + description=( + "Paste a JWT issued by your IdP (Azure AD, Auth0, Keycloak…). " + "Switch to the OAuth2 flow by setting " + "``AUTH_SWAGGER_OAUTH2_AUTHORIZATION_URL`` + " + "``AUTH_SWAGGER_OAUTH2_TOKEN_URL``." + ), + auto_error=False, + ) + + +# Scheme instance pinned at import time. ``Security(...)`` requires an +# instance, not a factory — settings only change when the process +# restarts, so reading them once at module load is safe in practice. +_bearer_scheme = _build_security_scheme(get_settings()) + + +def get_token_verifier(settings: Settings = Depends(get_settings)) -> TokenVerifier: + """Inject the production ``TokenVerifier``.""" + return build_token_verifier(settings) + + +def get_bearer_token( + credentials: HTTPAuthorizationCredentials | str | None = Security(_bearer_scheme), +) -> str: + """Extract the bearer token via the active OpenAPI security scheme. + + The scheme is either ``HTTPBearer`` (paste-once UX in Swagger) or + ``OAuth2AuthorizationCodeBearer`` (IdP redirect flow), picked at + module load by :func:`_build_security_scheme`. Both expose the bearer + token to FastAPI in compatible shapes: + + * ``HTTPBearer`` yields ``HTTPAuthorizationCredentials | None`` + * ``OAuth2AuthorizationCodeBearer`` yields ``str | None`` (the bare + token, already stripped of the ``Bearer`` prefix) + + Raises: + MissingTokenError: header absent or not in ``Bearer `` form. + """ + if credentials is None: + raise MissingTokenError() + if isinstance(credentials, str): + # OAuth2AuthorizationCodeBearer already returns the bare token. + if not credentials: + raise MissingTokenError(reason="malformed_authorization_header") + return credentials + if credentials.scheme.lower() != "bearer" or not credentials.credentials: + raise MissingTokenError(reason="malformed_authorization_header") + return credentials.credentials + + +def get_current_user( + token: str = Depends(get_bearer_token), + verifier: TokenVerifier = Depends(get_token_verifier), +) -> CurrentUser: + """Resolve and verify the caller identity. + + Raises: + MissingTokenError: 401 — no usable bearer header. + InvalidTokenError: 401 — token failed verification. + """ + return verifier.verify(token) + + +def get_current_user_optional( + request: Request, + verifier: TokenVerifier = Depends(get_token_verifier), +) -> CurrentUser | None: + """Return the verified caller identity, or ``None`` when unauthenticated. + + Use this for routes that work for both anonymous and authenticated + callers (e.g. listing public content but tailoring it when logged in). + A malformed or invalid token still raises — it's a client bug, not + an anonymous request. + """ + header = request.headers.get("Authorization") or request.headers.get("authorization") + if not header: + return None + scheme, _, token = header.partition(" ") + if scheme.lower() != "bearer" or not token: + raise MissingTokenError(reason="malformed_authorization_header") + return verifier.verify(token) + + +def require_roles(*roles: str) -> Callable[[CurrentUser], CurrentUser]: + """Build a dependency that enforces ``current_user.has_any_role(*roles)``. + + Usage:: + + @router.delete("/users/{id}", dependencies=[Depends(require_roles("admin"))]) + async def delete_user(...): ... + + Or pull the user too:: + + async def handler(user: CurrentUser = Depends(require_roles("admin"))): ... + """ + + def dependency(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + if not user.has_any_role(*roles): + raise InsufficientPermissionsError(required_roles=list(roles), held_roles=list(user.roles)) + return user + + return dependency diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/common.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/common.py new file mode 100644 index 0000000..07d8ce9 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/common.py @@ -0,0 +1,51 @@ +"""Shared FastAPI dependencies.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from fastapi import Request +from sqlalchemy.ext.asyncio import AsyncSession + +from {{ cookiecutter.package_name }}.application.clock import Clock +from {{ cookiecutter.package_name }}.application.id_generator import IdGenerator +from {{ cookiecutter.package_name }}.application.random_source import RandomSource +from {{ cookiecutter.package_name }}.infrastructure.config.settings import Settings, get_settings +from {{ cookiecutter.package_name }}.infrastructure.container import ( + build_clock, + build_id_generator, + build_random_source, +) + + +def get_settings_dependency() -> Settings: + """Inject the cached ``Settings`` singleton.""" + return get_settings() + + +def get_clock() -> Clock: + """Inject the production ``Clock``.""" + return build_clock() + + +def get_id_generator() -> IdGenerator: + """Inject the production ``IdGenerator``.""" + return build_id_generator() + + +def get_random_source() -> RandomSource: + """Inject the production ``RandomSource``.""" + return build_random_source() + + +async def get_session(request: Request) -> AsyncIterator[AsyncSession]: + """Open an async session AND a transaction for the lifetime of the request. + + The transaction commits when the route handler returns successfully and + rolls back if any exception bubbles up — including ``DomainError``, + which is intentional: the use case did not satisfy its post-condition, + so partial writes must not leak. See ADR 0004. + """ + factory = request.app.state.session_factory + async with factory() as session, session.begin(): + yield session diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/users.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/users.py new file mode 100644 index 0000000..d94a28c --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/dependencies/users.py @@ -0,0 +1,79 @@ +"""User-related FastAPI dependencies.""" + +from __future__ import annotations + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser +from {{ cookiecutter.package_name }}.application.clock import Clock +from {{ cookiecutter.package_name }}.application.dtos.user import ( + EnsureUserExistsInput, + EnsureUserExistsOutput, +) +from {{ cookiecutter.package_name }}.application.event_bus import EventBus +from {{ cookiecutter.package_name }}.application.id_generator import IdGenerator +from {{ cookiecutter.package_name }}.application.repositories.user import UserRepository +from {{ cookiecutter.package_name }}.application.use_cases.ensure_user_exists import ( + EnsureUserExistsUseCase, +) +from {{ cookiecutter.package_name }}.infrastructure.container import ( + build_ensure_user_exists_use_case, + build_event_bus, + build_user_repository, +) +from {{ cookiecutter.package_name }}.presentation.api.dependencies.auth import ( + get_current_user, +) +from {{ cookiecutter.package_name }}.presentation.api.dependencies.common import ( + get_clock, + get_id_generator, + get_session, +) + + +def get_user_repository(session: AsyncSession = Depends(get_session)) -> UserRepository: + """Inject ``SqlAlchemyUserRepository`` bound to the request session.""" + return build_user_repository(session) + + +def get_event_bus(session: AsyncSession = Depends(get_session)) -> EventBus: + """Inject ``SqlOutboxEventBus`` bound to the request session. + + Sharing the request session is intentional — the outbox row commits + with the entity write, never in isolation (ADR 0004 + ADR 0005). + """ + return build_event_bus(session) + + +def get_ensure_user_exists_use_case( + users: UserRepository = Depends(get_user_repository), + clock: Clock = Depends(get_clock), + ids: IdGenerator = Depends(get_id_generator), + events: EventBus = Depends(get_event_bus), +) -> EnsureUserExistsUseCase: + """Inject the ``EnsureUserExistsUseCase`` wired with its collaborators.""" + return build_ensure_user_exists_use_case( + users=users, clock=clock, ids=ids, events=events + ) + + +async def get_or_provision_user( + current_user: CurrentUser = Depends(get_current_user), + use_case: EnsureUserExistsUseCase = Depends(get_ensure_user_exists_use_case), +) -> EnsureUserExistsOutput: + """Chain JWT verification with lazy SSO auto-provisioning. + + Routes that need the caller's ``User`` row (the internal UUID, not + just the JWT subject) declare this dependency. On the first + authenticated request for a given JWT subject the use case provisions + a fresh ``User`` row from the JWT claims (``email``, ``name``); on + subsequent calls the existing row is returned. + + Raises: + MissingTokenError: 401 — no usable bearer header. + InvalidTokenError: 401 — token failed verification. + MissingProfileClaimsError: 401 — verified JWT lacks ``email`` / + ``name`` claims required to provision the user. + """ + return await use_case.execute(EnsureUserExistsInput(current_user=current_user)) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/error_handlers.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/error_handlers.py new file mode 100644 index 0000000..42b50b9 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/error_handlers.py @@ -0,0 +1,96 @@ +"""Centralised exception → HTTP mapping. + +The mapping ``ERROR_HTTP_MAPPING`` is the single source of truth for the HTTP +status code returned for each ``DomainError`` subclass. A test in +``tests/unit/presentation/`` iterates ``DomainError.__subclasses__()`` and +fails if a subclass is missing here. +""" + +from __future__ import annotations + +from typing import Any + +import structlog +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from opentelemetry import trace + +from {{ cookiecutter.package_name }}.domain.exceptions.auth import ( + InsufficientPermissionsError, + InvalidTokenError, + MissingProfileClaimsError, + MissingTokenError, +) +from {{ cookiecutter.package_name }}.domain.exceptions.base import DomainError +from {{ cookiecutter.package_name }}.domain.exceptions.user import UserAlreadyExistsError, UserNotFoundError +from {{ cookiecutter.package_name }}.domain.value_objects.email import InvalidEmailError + +logger = structlog.get_logger(__name__) + +ERROR_HTTP_MAPPING: dict[type[DomainError], int] = { + InvalidEmailError: 422, + UserAlreadyExistsError: status.HTTP_409_CONFLICT, + UserNotFoundError: status.HTTP_404_NOT_FOUND, + MissingTokenError: status.HTTP_401_UNAUTHORIZED, + InvalidTokenError: status.HTTP_401_UNAUTHORIZED, + InsufficientPermissionsError: status.HTTP_403_FORBIDDEN, + MissingProfileClaimsError: status.HTTP_401_UNAUTHORIZED, + # Concrete subclasses are added by their owning bounded context. + # The mapping is checked by tests/unit/presentation/test_error_mapping.py. +} + + +def _http_status_for(exc: DomainError) -> int: + return ERROR_HTTP_MAPPING.get(type(exc), status.HTTP_500_INTERNAL_SERVER_ERROR) + + +def _current_trace_id() -> str | None: + span = trace.get_current_span() + if span is None: + return None + ctx = span.get_span_context() + if not ctx.is_valid: + return None + return f"{ctx.trace_id:032x}" + + +async def handle_domain_error(request: Request, exc: DomainError) -> JSONResponse: + """Translate a ``DomainError`` into a structured JSON response.""" + logger.warning( + "domain_error", + code=exc.code, + path=request.url.path, + context=exc.context, + ) + return JSONResponse( + status_code=_http_status_for(exc), + content={"error": {"code": exc.code, "message": exc.message}}, + ) + + +async def handle_unexpected_error(request: Request, exc: Exception) -> JSONResponse: # noqa: ARG001 + """Translate any uncaught exception into a 500 with a trace id for support.""" + trace_id = _current_trace_id() + logger.exception( + "unexpected_error", + path=request.url.path, + trace_id=trace_id, + ) + payload: dict[str, Any] = { + "error": { + "code": "INTERNAL_ERROR", + "message": "An internal error occurred. Contact support with the trace identifier.", + } + } + if trace_id: + payload["error"]["trace_id"] = trace_id + return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=payload) + + +def register_error_handlers(app: FastAPI) -> None: + """Register the domain and fallback handlers on the FastAPI app.""" + # Starlette's overload set does not include the (Request, Exception) -> JSONResponse + # signature returned by our async handlers; FastAPI accepts it at runtime via + # adapter logic, so we intentionally bypass the static check here. + app.add_exception_handler(DomainError, handle_domain_error) # ty: ignore[invalid-argument-type] + app.add_exception_handler(Exception, handle_unexpected_error) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/health.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/health.py new file mode 100644 index 0000000..74c940e --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/health.py @@ -0,0 +1,62 @@ +"""Kubernetes-style health endpoints. + +Three probes mapping to the K8s probe semantics: + * ``/health/live`` — process is responsive. No downstream check. + Failing causes a pod restart. + * ``/health/ready`` — app can serve traffic right now (DB reachable). + Failing removes the pod from the load balancer + but does NOT restart it. + * ``/health/startup`` — app has finished its boot routine. Same DB check + as ready, but with longer K8s timeouts allowed + before liveness/readiness kick in. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, status +from fastapi.responses import JSONResponse +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from {{ cookiecutter.package_name }}.presentation.api.dependencies.common import get_session + +router = APIRouter(prefix="/health", tags=["health"]) + + +@router.get("/live") +async def live() -> dict[str, str]: + """Liveness probe — process is responsive. + + Never checks downstream dependencies: a degraded DB must not trigger + pod restarts, only LB removal via ``/health/ready``. + """ + return {"status": "live"} + + +@router.get("/ready") +async def ready(session: AsyncSession = Depends(get_session)) -> JSONResponse: + """Readiness probe — app can serve traffic right now.""" + return await _check_database(session, ready_label="ready", failed_label="not_ready") + + +@router.get("/startup") +async def startup(session: AsyncSession = Depends(get_session)) -> JSONResponse: + """Startup probe — app has finished its boot routine. + + Implementation-wise identical to ``/health/ready``; K8s applies looser + timeouts so slow boots (warm caches, schema migrations) do not trip + liveness while the pod is still coming up. + """ + return await _check_database(session, ready_label="started", failed_label="not_started") + + +async def _check_database(session: AsyncSession, *, ready_label: str, failed_label: str) -> JSONResponse: + try: + await session.execute(text("SELECT 1")) + except SQLAlchemyError: + return JSONResponse( + content={"status": failed_label, "reason": "database_unreachable"}, + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + return JSONResponse(content={"status": ready_label}, status_code=status.HTTP_200_OK) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/__init__.py new file mode 100644 index 0000000..9b51c1d --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/__init__.py @@ -0,0 +1,17 @@ +"""Application middlewares.""" + +from {{ cookiecutter.package_name }}.presentation.api.middleware.access_log import AccessLogMiddleware +from {{ cookiecutter.package_name }}.presentation.api.middleware.body_size import BodySizeLimitMiddleware +from {{ cookiecutter.package_name }}.presentation.api.middleware.idempotency import IdempotencyMiddleware +from {{ cookiecutter.package_name }}.presentation.api.middleware.request_id import RequestIdMiddleware +from {{ cookiecutter.package_name }}.presentation.api.middleware.security_headers import SecurityHeadersMiddleware +from {{ cookiecutter.package_name }}.presentation.api.middleware.timeout import RequestTimeoutMiddleware + +__all__ = [ + "AccessLogMiddleware", + "BodySizeLimitMiddleware", + "IdempotencyMiddleware", + "RequestIdMiddleware", + "RequestTimeoutMiddleware", + "SecurityHeadersMiddleware", +] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/access_log.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/access_log.py new file mode 100644 index 0000000..2c4938b --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/access_log.py @@ -0,0 +1,40 @@ +"""Access log middleware — one structured line per HTTP request.""" + +from __future__ import annotations + +import time +from collections.abc import Awaitable, Callable + +import structlog +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +logger = structlog.get_logger("http.access") + +# Endpoints whose volume would drown the log stream and that bring no value +# to ingest. Kept short on purpose: anything user-meaningful is logged. +SILENT_PATHS = ("/health/live", "/health/ready", "/health/startup", "/metrics") + + +class AccessLogMiddleware(BaseHTTPMiddleware): + """Emit one ``http.request`` event with method, path, status, duration.""" + + async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: + """Time the request, then log a single structured event.""" + if request.url.path in SILENT_PATHS: + return await call_next(request) + + start = time.perf_counter() + response = await call_next(request) + duration_ms = round((time.perf_counter() - start) * 1000, 2) + + logger.info( + "http.request", + method=request.method, + path=request.url.path, + status=response.status_code, + duration_ms=duration_ms, + client=request.client.host if request.client else None, + ) + return response diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/body_size.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/body_size.py new file mode 100644 index 0000000..a05a9a0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/body_size.py @@ -0,0 +1,45 @@ +"""Reject requests whose declared body exceeds the configured limit.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import ASGIApp + + +class BodySizeLimitMiddleware(BaseHTTPMiddleware): + """Reject oversized requests early via the ``Content-Length`` header. + + Trusts ``Content-Length`` (set by sane HTTP clients). Chunked uploads + without ``Content-Length`` slip past this gate by design — pair the + middleware with an upstream proxy size limit (Ingress / Envoy) for + defence in depth. + """ + + def __init__(self, app: ASGIApp, *, max_bytes: int) -> None: + super().__init__(app) + self.max_bytes = max_bytes + + async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: + """Return 413 when ``Content-Length`` exceeds ``max_bytes``.""" + content_length = request.headers.get("content-length") + if content_length is not None: + try: + declared = int(content_length) + except ValueError: + declared = 0 + if declared > self.max_bytes: + return JSONResponse( + status_code=413, + content={ + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": f"Request body exceeds the {self.max_bytes}-byte limit.", + } + }, + ) + return await call_next(request) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/idempotency.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/idempotency.py new file mode 100644 index 0000000..fdbe53e --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/idempotency.py @@ -0,0 +1,288 @@ +"""Idempotency middleware. + +Clients add ``Idempotency-Key: `` on retry-eligible writes +(POST, PUT, PATCH, DELETE). The middleware: + +* On the first request, inserts a row with ``status='in_progress'`` then + runs the handler. After the handler returns successfully, the row is + updated to ``status='completed'`` with the captured response. +* On a retry with the same key: + + - if the original is ``completed`` and the method+path match, the + cached response is returned **without re-running the handler**. + - if the original is still ``in_progress`` (a race or a stalled + earlier attempt that has not yet expired), the retry is rejected + with 409 ``IDEMPOTENCY_IN_PROGRESS``. + - if the method+path mismatch, the retry is rejected with 422 + ``IDEMPOTENCY_KEY_MISMATCH``. + - if the row has expired, it is deleted and the request is treated as + fresh. + +The middleware uses its own session (from +``request.app.state.session_factory``) so the idempotency lock commits +independently of the request transaction. + +Limitations (documented in the ``adding-idempotency-safety`` skill): + +* Request body is NOT hashed; mismatched-body retries with the same + method+path silently return the cached reply. Validate at the schema + level if precise replay matters. +* Streaming responses are passed through without caching — only the + first reply is sent; logs a warning. +""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +import structlog +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.idempotency_record import IdempotencyRecord + +logger = structlog.get_logger(__name__) + +IDEMPOTENCY_HEADER = "Idempotency-Key" +_KEY_MAX_LENGTH = 255 + + +class IdempotencyMiddleware(BaseHTTPMiddleware): + """Cache responses keyed by ``Idempotency-Key`` for retry-eligible methods.""" + + def __init__( + self, + app: Any, + *, + methods: frozenset[str], + ttl_seconds: int, + ) -> None: + super().__init__(app) + self._methods = methods + self._ttl = timedelta(seconds=ttl_seconds) + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + """Resolve idempotency for the current request and dispatch the handler.""" + if request.method.upper() not in self._methods: + return await call_next(request) + + key = request.headers.get(IDEMPOTENCY_HEADER) + if not key: + return await call_next(request) + if len(key) > _KEY_MAX_LENGTH: + return _error_response( + status_code=400, + code="IDEMPOTENCY_KEY_TOO_LONG", + message=f"Idempotency-Key exceeds {_KEY_MAX_LENGTH} characters.", + ) + + factory = getattr(request.app.state, "session_factory", None) + if factory is None: + # Tests / health probes can stub out session_factory; degrade quietly. + logger.warning("idempotency.no_session_factory", key=key) + return await call_next(request) + + method = request.method.upper() + path = request.url.path + now = datetime.now(tz=UTC) + + # 1. Try to claim the key with status='in_progress'. On conflict, inspect + # the existing row and either replay, reject, or recycle (expired). + claim_result = await self._claim_key(factory, key=key, method=method, path=path, now=now) + if isinstance(claim_result, Response): + return claim_result + + # 2. Run the handler. + try: + response = await call_next(request) + except Exception: + # Leave the in-progress row in place; it will expire and unblock the + # next attempt. Do NOT delete here: a quick retry must still see the + # in-progress state to avoid double work. + raise + + # 3. Capture the response body so we can replay it AND so we can return + # a fresh Response (the body iterator can only be consumed once). + body_bytes = b"" + async for chunk in response.body_iterator: + body_bytes += chunk if isinstance(chunk, bytes) else chunk.encode() + + await self._complete( + factory, + key=key, + response_status=response.status_code, + body_bytes=body_bytes, + headers=dict(response.headers), + now=datetime.now(tz=UTC), + ) + + return Response( + content=body_bytes, + status_code=response.status_code, + headers={k: v for k, v in response.headers.items() if k.lower() != "content-length"}, + media_type=response.media_type, + ) + + async def _claim_key( + self, + factory: Any, + *, + key: str, + method: str, + path: str, + now: datetime, + ) -> Response | None: + """Atomic-ish: try to insert in_progress, fall back to inspecting an existing row. + + Returns ``None`` when the caller should run the handler, or a + ``Response`` to return immediately. + """ + async with factory() as session, session.begin(): + session.add( + IdempotencyRecord( + key=key, + status="in_progress", + method=method, + path=path, + created_at=now, + expires_at=now + self._ttl, + ) + ) + try: + await session.flush() + except IntegrityError: + await session.rollback() + return await self._inspect_existing(factory, key=key, method=method, path=path, now=now) + return None + + async def _inspect_existing( + self, + factory: Any, + *, + key: str, + method: str, + path: str, + now: datetime, + ) -> Response | None: + """Decide what to return for a retry on a key that already exists. + + Returns ``None`` when the existing row was expired and got recycled, + meaning the caller should fall through to running the handler. + Returns a ``Response`` to return to the client otherwise. + """ + async with factory() as session, session.begin(): + existing = await session.scalar( + select(IdempotencyRecord).where(IdempotencyRecord.key == key) + ) + if existing is None: + # Race: the row was deleted between our INSERT and this SELECT. + # Behave as if the key was free — the caller will retry the claim. + # Re-raising is uglier than logging: degrade quietly. + logger.warning("idempotency.row_disappeared", key=key) + return _error_response( + status_code=409, + code="IDEMPOTENCY_IN_PROGRESS", + message="Idempotency key is being processed; retry shortly.", + ) + + if existing.expires_at <= now: + # Expired — clear it and tell the caller to treat as fresh. + await session.delete(existing) + # Synthesize a fresh in-progress row in this same transaction so + # we keep the lock semantics. + session.add( + IdempotencyRecord( + key=key, + status="in_progress", + method=method, + path=path, + created_at=now, + expires_at=now + self._ttl, + ) + ) + return None + + if existing.method != method or existing.path != path: + return _error_response( + status_code=422, + code="IDEMPOTENCY_KEY_MISMATCH", + message=( + f"Idempotency-Key was first used for {existing.method} {existing.path}; " + f"now reused on {method} {path}." + ), + ) + + if existing.status == "in_progress": + return _error_response( + status_code=409, + code="IDEMPOTENCY_IN_PROGRESS", + message="A request with this Idempotency-Key is still being processed.", + ) + + # status == 'completed' — replay the original response. + body_bytes = ( + json.dumps(existing.response_body).encode() + if existing.response_body is not None + else b"" + ) + headers = { + k: v + for k, v in (existing.response_headers or {}).items() + if k.lower() != "content-length" + } + return Response( + content=body_bytes, + status_code=existing.response_status or 200, + headers=headers, + media_type="application/json", + ) + + async def _complete( # noqa: PLR0913 — every kw is part of one cached HTTP response + self, + factory: Any, + *, + key: str, + response_status: int, + body_bytes: bytes, + headers: dict[str, str], + now: datetime, + ) -> None: + """Mark the in-progress row as completed and persist the response.""" + try: + decoded_body = json.loads(body_bytes) if body_bytes else None + except json.JSONDecodeError: + # Non-JSON response (file download, plain text) — store the raw + # bytes under a sentinel key; replay will best-effort serve them. + logger.warning("idempotency.non_json_body", key=key) + decoded_body = {"__raw__": body_bytes.decode(errors="replace")} + + async with factory() as session, session.begin(): + row = await session.scalar( + select(IdempotencyRecord).where(IdempotencyRecord.key == key) + ) + if row is None: + logger.warning("idempotency.row_missing_on_complete", key=key) + return + row.status = "completed" + row.response_status = response_status + row.response_body = decoded_body + row.response_headers = headers + row.completed_at = now + + +def _error_response(*, status_code: int, code: str, message: str) -> JSONResponse: + """Build an error response matching the project's JSON error shape.""" + return JSONResponse( + status_code=status_code, + content={"error": {"code": code, "message": message}}, + ) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/request_id.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/request_id.py new file mode 100644 index 0000000..76e58f8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/request_id.py @@ -0,0 +1,36 @@ +"""Request ID middleware — correlation identifier propagation.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from uuid import uuid4 + +import structlog +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +REQUEST_ID_HEADER = "X-Request-ID" + + +class RequestIdMiddleware(BaseHTTPMiddleware): + """Bind a request identifier to logs and echo it on the response. + + Reads an incoming ``X-Request-ID`` header (trusting upstream proxies that + propagate one) or generates a fresh UUID4. The id is bound to the + structlog context so every log emitted during the request includes it, + and is exposed back to the caller via the same header for end-to-end + correlation. + """ + + async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: + """Generate or honour the request id, bind it to logs, echo it back.""" + request_id = request.headers.get(REQUEST_ID_HEADER) or uuid4().hex + request.state.request_id = request_id + structlog.contextvars.bind_contextvars(request_id=request_id) + try: + response = await call_next(request) + finally: + structlog.contextvars.unbind_contextvars("request_id") + response.headers.setdefault(REQUEST_ID_HEADER, request_id) + return response diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/security_headers.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/security_headers.py new file mode 100644 index 0000000..218696e --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/security_headers.py @@ -0,0 +1,33 @@ +"""Adds defensive security headers to every response.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import ASGIApp + +DEFAULT_HEADERS: dict[str, str] = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Content-Security-Policy": "default-src 'self'", +} + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Sets a baseline of security headers on every HTTP response.""" + + def __init__(self, app: ASGIApp, headers: dict[str, str] | None = None) -> None: + super().__init__(app) + self.headers = {**DEFAULT_HEADERS, **(headers or {})} + + async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: + """Propagate the request and decorate the response with security headers.""" + response = await call_next(request) + for name, value in self.headers.items(): + response.headers.setdefault(name, value) + return response diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/timeout.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/timeout.py new file mode 100644 index 0000000..e9bb3ee --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/middleware/timeout.py @@ -0,0 +1,49 @@ +"""Per-request timeout middleware.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import structlog +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import ASGIApp + +logger = structlog.get_logger(__name__) + + +class RequestTimeoutMiddleware(BaseHTTPMiddleware): + """Cap the wall-clock duration of each request. + + Returns 504 Gateway Timeout when the configured budget is exceeded. The + underlying coroutine is cancelled, so DB connections and HTTP clients + are released through their normal context-manager teardown. + """ + + def __init__(self, app: ASGIApp, *, timeout_seconds: float) -> None: + super().__init__(app) + self.timeout_seconds = timeout_seconds + + async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: + """Run the request with an asyncio timeout, return 504 on overrun.""" + try: + return await asyncio.wait_for(call_next(request), timeout=self.timeout_seconds) + except TimeoutError: + logger.warning( + "http.timeout", + method=request.method, + path=request.url.path, + timeout_seconds=self.timeout_seconds, + ) + return JSONResponse( + status_code=504, + content={ + "error": { + "code": "REQUEST_TIMEOUT", + "message": f"The request exceeded the {self.timeout_seconds}-second budget.", + } + }, + ) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/schemas/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/schemas/__init__.py new file mode 100644 index 0000000..0626cf4 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/schemas/__init__.py @@ -0,0 +1,5 @@ +"""Pydantic request / response schemas. + +Suffix ``Schema`` to disambiguate from domain entities (e.g. +``UserCreateSchema``, ``UserReadSchema``, ``ErrorResponseSchema``). +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/schemas/auth.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/schemas/auth.py new file mode 100644 index 0000000..68d2032 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/schemas/auth.py @@ -0,0 +1,13 @@ +"""Pydantic schemas for auth-related responses.""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class WhoamiResponse(BaseModel): + """Response body for ``GET /v1/auth/whoami``.""" + + subject: str + email: str | None = None + roles: list[str] = [] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/schemas/user.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/schemas/user.py new file mode 100644 index 0000000..22ff566 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/schemas/user.py @@ -0,0 +1,18 @@ +"""Pydantic schemas for the user resource.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class UserReadSchema(BaseModel): + """Response body for user reads (``GET /v1/users/me``).""" + + id: UUID + subject: str + email: str + name: str + created_at: datetime diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/v1/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/v1/__init__.py new file mode 100644 index 0000000..a3a908c --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/v1/__init__.py @@ -0,0 +1,5 @@ +"""API v1 routers. + +One module per resource. Routers are wired into the FastAPI app in +``app.py``. +""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/v1/auth.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/v1/auth.py new file mode 100644 index 0000000..e53115b --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/v1/auth.py @@ -0,0 +1,26 @@ +"""Auth-related v1 endpoints. + +Currently exposes ``GET /v1/auth/whoami`` as the canonical example of a +protected route. Real projects add their own endpoints; see the +``adding-auth`` skill for the protection patterns. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser +from {{ cookiecutter.package_name }}.presentation.api.dependencies.auth import get_current_user +from {{ cookiecutter.package_name }}.presentation.api.schemas.auth import WhoamiResponse + +router = APIRouter(prefix="/v1/auth", tags=["auth"]) + + +@router.get( + "/whoami", + response_model=WhoamiResponse, + summary="Return the authenticated caller's identity", +) +async def whoami(user: CurrentUser = Depends(get_current_user)) -> WhoamiResponse: + """Echo the verified caller identity. Used as the auth probe by the e2e tests.""" + return WhoamiResponse(subject=user.subject, email=user.email, roles=list(user.roles)) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/v1/users.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/v1/users.py new file mode 100644 index 0000000..06bc739 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/presentation/api/v1/users.py @@ -0,0 +1,33 @@ +"""User endpoints (API v1).""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from {{ cookiecutter.package_name }}.application.dtos.user import EnsureUserExistsOutput +from {{ cookiecutter.package_name }}.presentation.api.dependencies.users import ( + get_or_provision_user, +) +from {{ cookiecutter.package_name }}.presentation.api.schemas.user import UserReadSchema + +router = APIRouter(prefix="/v1/users", tags=["users"]) + + +@router.get("/me", response_model=UserReadSchema) +async def get_current_user_profile( + user: EnsureUserExistsOutput = Depends(get_or_provision_user), +) -> UserReadSchema: + """Return the caller's ``User`` row, auto-provisioning on first call. + + The dependency chain verifies the JWT, then maps its subject to a + persisted ``User`` (creating one from the JWT claims on first sight). + The handler simply projects the use case output to the response + schema. + """ + return UserReadSchema( + id=user.id, + subject=user.subject, + email=user.email, + name=user.name, + created_at=user.created_at, + ) diff --git a/{{cookiecutter.project_slug}}/tests/__init__.py b/{{cookiecutter.project_slug}}/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/conftest.py b/{{cookiecutter.project_slug}}/tests/conftest.py new file mode 100644 index 0000000..9db9c1b --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/conftest.py @@ -0,0 +1,121 @@ +"""Root conftest — primitives shared by all three test layers. + +Two distinct families of fixtures live here: + +* **In-memory Fakes** — `frozen_clock`, `sequential_ids`, `seeded_random`, + `user_repository`, `event_bus`, `token_verifier`, `ensure_user_exists_use_case`. + Used exclusively by `tests/unit/` (the use cases and the pure helpers). Fakes + never leak into integration or e2e — those layers exercise the real adapters. +* **Testcontainers Postgres** — `pg_url` and `pg_engine` (session-scoped). Both + `tests/integration/` and `tests/e2e/` consume them; the per-test transaction + wrapping is provided by each layer's own conftest (`pg_session` for + integration, `e2e_session_factory` for e2e). +""" + +from __future__ import annotations + +import contextlib +from collections.abc import AsyncIterator, Iterator +from datetime import UTC, datetime + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from testcontainers.postgres import PostgresContainer + +from {{ cookiecutter.package_name }}.application.use_cases.ensure_user_exists import ( + EnsureUserExistsUseCase, +) +from {{ cookiecutter.package_name }}.infrastructure.persistence.models import Base + +from tests.fakes.auth import FakeTokenVerifier +from tests.fakes.clock import FrozenClock +from tests.fakes.event_bus import InMemoryEventBus +from tests.fakes.id_generator import SequentialIdGenerator +from tests.fakes.random_source import SeededRandomSource +from tests.fakes.repositories.user import InMemoryUserRepository + +DEFAULT_TEST_INSTANT = datetime(2026, 5, 5, 12, 0, tzinfo=UTC) + + +# ─── Primitive fakes ───────────────────────────────────────────────── +@pytest.fixture +def frozen_clock() -> FrozenClock: + return FrozenClock(at=DEFAULT_TEST_INSTANT) + + +@pytest.fixture +def sequential_ids() -> SequentialIdGenerator: + return SequentialIdGenerator(start=1) + + +@pytest.fixture +def seeded_random() -> SeededRandomSource: + return SeededRandomSource(seed=0) + + +# ─── Repository fake (also acts as Spy via .users) ─────────────────── +@pytest.fixture +def user_repository() -> InMemoryUserRepository: + return InMemoryUserRepository() + + +# ─── Event bus fake (also acts as Spy via .published) ──────────────── +@pytest.fixture +def event_bus() -> InMemoryEventBus: + return InMemoryEventBus() + + +# ─── Token verifier fake (also acts as Spy via .calls) ─────────────── +@pytest.fixture +def token_verifier() -> FakeTokenVerifier: + return FakeTokenVerifier() + + +# ─── Use case composed for unit tests ──────────────────────────────── +@pytest.fixture +def ensure_user_exists_use_case( + user_repository: InMemoryUserRepository, + frozen_clock: FrozenClock, + sequential_ids: SequentialIdGenerator, + event_bus: InMemoryEventBus, +) -> EnsureUserExistsUseCase: + return EnsureUserExistsUseCase( + users=user_repository, + clock=frozen_clock, + ids=sequential_ids, + events=event_bus, + ) + + +# ─── Testcontainers Postgres (shared by integration + e2e) ─────────── +@pytest.fixture(scope="session") +def pg_url() -> Iterator[str]: + """Spawn one Postgres 17 container for the whole test session. + + Skips integration and e2e tests cleanly when the Docker daemon is not + reachable, instead of letting testcontainers raise a noisy stack. + The Docker SDK probes the daemon as soon as ``PostgresContainer`` is + instantiated, so we wrap both construction and ``start()``. + """ + try: + container = PostgresContainer("postgres:17") + container.start() + except Exception as exc: + pytest.skip(f"Docker daemon not reachable; integration/e2e tests skipped: {exc}") + try: + sync_url = container.get_connection_url() + yield sync_url.replace("postgresql+psycopg2", "postgresql+asyncpg") + finally: + with contextlib.suppress(Exception): + container.stop() + + +@pytest_asyncio.fixture(scope="session") +async def pg_engine(pg_url: str) -> AsyncIterator[AsyncEngine]: + """Build the async engine and create the schema once per session.""" + engine = create_async_engine(pg_url, future=True) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() diff --git a/{{cookiecutter.project_slug}}/tests/e2e/__init__.py b/{{cookiecutter.project_slug}}/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/e2e/api/__init__.py b/{{cookiecutter.project_slug}}/tests/e2e/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/e2e/api/test_auth_endpoints.py b/{{cookiecutter.project_slug}}/tests/e2e/api/test_auth_endpoints.py new file mode 100644 index 0000000..c39a503 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/e2e/api/test_auth_endpoints.py @@ -0,0 +1,77 @@ +"""E2E tests for ``GET /v1/auth/whoami`` — the canonical protected route.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest +from httpx import AsyncClient + + +@pytest.mark.e2e +async def test_given_valid_bearer_token_when_calling_whoami_then_returns_identity( + client: AsyncClient, + mint_token: Callable[..., str], +) -> None: + # GIVEN + token = mint_token("alice", email="alice@example.com", roles=("admin",)) + + # WHEN + response = await client.get( + "/v1/auth/whoami", headers={"Authorization": f"Bearer {token}"} + ) + + # THEN + assert (response.status_code, response.json()) == ( + 200, + {"subject": "alice", "email": "alice@example.com", "roles": ["admin"]}, + ) + + +@pytest.mark.e2e +async def test_given_no_authorization_header_when_calling_whoami_then_returns_401( + client: AsyncClient, +) -> None: + # GIVEN: no header set + + # WHEN + response = await client.get("/v1/auth/whoami") + + # THEN + assert (response.status_code, response.json()["error"]["code"]) == ( + 401, + "AUTH_MISSING_TOKEN", + ) + + +@pytest.mark.e2e +async def test_given_unsigned_token_when_calling_whoami_then_returns_401( + client: AsyncClient, +) -> None: + # GIVEN: bearer that wasn't signed with the e2e secret + + # WHEN + response = await client.get( + "/v1/auth/whoami", headers={"Authorization": "Bearer unknown"} + ) + + # THEN + assert (response.status_code, response.json()["error"]["code"]) == ( + 401, + "AUTH_INVALID_TOKEN", + ) + + +@pytest.mark.e2e +async def test_given_non_bearer_scheme_when_calling_whoami_then_returns_401( + client: AsyncClient, +) -> None: + # GIVEN: non-Bearer scheme + + # WHEN + response = await client.get( + "/v1/auth/whoami", headers={"Authorization": "Basic abc"} + ) + + # THEN + assert response.status_code == 401 diff --git a/{{cookiecutter.project_slug}}/tests/e2e/api/test_health_endpoints.py b/{{cookiecutter.project_slug}}/tests/e2e/api/test_health_endpoints.py new file mode 100644 index 0000000..5cd5f6a --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/e2e/api/test_health_endpoints.py @@ -0,0 +1,70 @@ +"""E2E tests for the three K8s health probes.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import AsyncSession + +from {{ cookiecutter.package_name }}.presentation.api.dependencies.common import get_session + +from tests.e2e.conftest import StubAsyncSession + + +@pytest.mark.e2e +async def test_given_running_app_when_calling_live_then_returns_200( + client: AsyncClient, +) -> None: + # WHEN + response = await client.get("/health/live") + + # THEN + assert (response.status_code, response.json()) == (200, {"status": "live"}) + + +@pytest.mark.e2e +async def test_given_db_reachable_when_calling_ready_then_returns_200( + client: AsyncClient, +) -> None: + # WHEN + response = await client.get("/health/ready") + + # THEN + assert (response.status_code, response.json()) == (200, {"status": "ready"}) + + +@pytest.mark.e2e +async def test_given_db_unreachable_when_calling_ready_then_returns_503( + app: FastAPI, client: AsyncClient +) -> None: + # GIVEN + failing = StubAsyncSession(raises=OperationalError("db down", params=None, orig=None)) + + async def failing_session() -> AsyncIterator[AsyncSession]: + yield failing # type: ignore[misc] + + app.dependency_overrides[get_session] = failing_session + + # WHEN + response = await client.get("/health/ready") + + # THEN + assert (response.status_code, response.json()) == ( + 503, + {"status": "not_ready", "reason": "database_unreachable"}, + ) + + +@pytest.mark.e2e +async def test_given_db_reachable_when_calling_startup_then_returns_200( + client: AsyncClient, +) -> None: + # WHEN + response = await client.get("/health/startup") + + # THEN + assert (response.status_code, response.json()) == (200, {"status": "started"}) diff --git a/{{cookiecutter.project_slug}}/tests/e2e/api/test_metrics.py b/{{cookiecutter.project_slug}}/tests/e2e/api/test_metrics.py new file mode 100644 index 0000000..85ea1ce --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/e2e/api/test_metrics.py @@ -0,0 +1,42 @@ +"""E2E test for the Prometheus /metrics endpoint.""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient + + +@pytest.mark.e2e +async def test_given_running_app_when_calling_metrics_then_content_type_is_text_plain( + client: AsyncClient, +) -> None: + # WHEN + response = await client.get("/metrics") + + # THEN + assert "text/plain" in response.headers.get("content-type", "") + + +@pytest.mark.e2e +async def test_given_running_app_when_calling_metrics_then_body_includes_process_metrics( + client: AsyncClient, +) -> None: + # WHEN + response = await client.get("/metrics") + + # THEN: prometheus_client ships process_cpu_seconds_total unconditionally + assert "process_cpu_seconds_total" in response.text + + +@pytest.mark.e2e +async def test_given_a_served_request_when_calling_metrics_then_body_includes_http_metrics( + client: AsyncClient, +) -> None: + # GIVEN: trigger a request to populate at least one HTTP metric series + await client.get("/health/live") + + # WHEN + text = (await client.get("/metrics")).text + + # THEN + assert "http_request_duration_seconds" in text or "http_requests_total" in text diff --git a/{{cookiecutter.project_slug}}/tests/e2e/api/test_middleware.py b/{{cookiecutter.project_slug}}/tests/e2e/api/test_middleware.py new file mode 100644 index 0000000..d40a34d --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/e2e/api/test_middleware.py @@ -0,0 +1,60 @@ +"""E2E tests for the HTTP middleware stack. + +Covers the user-facing behaviour: header echo for ``X-Request-ID``, 413 on +oversized payload. Access log and timeout are also exercised at runtime +but their side-effects (a structured log line, asyncio cancellation) are +out of scope here — they are validated indirectly by smoke testing the +response and would otherwise need log capture / a slow handler harness. +""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient + + +@pytest.mark.e2e +async def test_given_request_without_request_id_when_completing_then_response_has_generated_32_char_id( + client: AsyncClient, +) -> None: + # GIVEN: client without any custom header + # WHEN + response = await client.get("/health/live") + + # THEN: UUID4 hex form is 32 lowercase hex chars; len == 32 implies presence + assert len(response.headers.get("X-Request-ID", "")) == 32 + + +@pytest.mark.e2e +async def test_given_request_with_request_id_when_completing_then_id_is_echoed( + client: AsyncClient, +) -> None: + # GIVEN + upstream_id = "abc123def456" + + # WHEN + response = await client.get("/health/live", headers={"X-Request-ID": upstream_id}) + + # THEN + assert response.headers["X-Request-ID"] == upstream_id + + +@pytest.mark.e2e +async def test_given_oversized_payload_when_posting_then_response_is_413_payload_too_large( + client: AsyncClient, +) -> None: + # GIVEN: request advertises a body two megabytes large (above the 1 MiB default) + huge_size = 2 * 1024 * 1024 + + # WHEN + response = await client.post( + "/v1/users", + headers={"Content-Length": str(huge_size), "Content-Type": "application/json"}, + content=b"{}", + ) + + # THEN: status + error code describe the same response outcome (§4b tuple equality) + assert (response.status_code, response.json()["error"]["code"]) == ( + 413, + "PAYLOAD_TOO_LARGE", + ) diff --git a/{{cookiecutter.project_slug}}/tests/e2e/api/test_users_me_endpoint.py b/{{cookiecutter.project_slug}}/tests/e2e/api/test_users_me_endpoint.py new file mode 100644 index 0000000..a1431f0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/e2e/api/test_users_me_endpoint.py @@ -0,0 +1,152 @@ +"""E2E tests for ``GET /v1/users/me`` (SSO auto-provisioning entry point). + +Exercises the production wiring end-to-end: real JWT verification +(HS256 against the e2e secret), real ``SqlAlchemyUserRepository`` and +``SqlOutboxEventBus`` against testcontainers Postgres, real outbox +inserts inside the request transaction. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.user import UserModel + + +@pytest.mark.e2e +async def test_given_valid_jwt_when_getting_me_then_returns_200( + client: AsyncClient, + mint_token: Callable[..., str], +) -> None: + # GIVEN + token = mint_token("azure-sub-alice", email="alice@example.com", name="Alice") + + # WHEN + response = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {token}"}) + + # THEN + assert response.status_code == 200 + + +@pytest.mark.e2e +async def test_given_valid_jwt_when_getting_me_then_body_carries_provisioned_identity( + client: AsyncClient, + mint_token: Callable[..., str], +) -> None: + # GIVEN + token = mint_token("azure-sub-alice", email="alice@example.com", name="Alice") + + # WHEN + response = await client.get( + "/v1/users/me", headers={"Authorization": f"Bearer {token}"} + ) + body = response.json() + + # THEN + assert {k: body[k] for k in ("subject", "email", "name")} == { + "subject": "azure-sub-alice", + "email": "alice@example.com", + "name": "Alice", + } + + +@pytest.mark.e2e +async def test_given_first_call_for_subject_when_getting_me_then_user_row_is_inserted( + client: AsyncClient, + mint_token: Callable[..., str], + e2e_session_factory: async_sessionmaker[AsyncSession], +) -> None: + # GIVEN + token = mint_token("azure-sub-alice", email="alice@example.com", name="Alice") + + # WHEN + await client.get("/v1/users/me", headers={"Authorization": f"Bearer {token}"}) + + # THEN + async with e2e_session_factory() as session: + count = await session.scalar( + select(func.count()).select_from(UserModel).where(UserModel.subject == "azure-sub-alice") + ) + assert count == 1 + + +@pytest.mark.e2e +async def test_given_no_authorization_header_when_getting_me_then_returns_401( + client: AsyncClient, +) -> None: + # WHEN + response = await client.get("/v1/users/me") + + # THEN + assert response.status_code == 401 + + +@pytest.mark.e2e +async def test_given_no_authorization_header_when_getting_me_then_error_code_is_missing_token( + client: AsyncClient, +) -> None: + # WHEN + body = (await client.get("/v1/users/me")).json() + + # THEN + assert body["error"]["code"] == "AUTH_MISSING_TOKEN" + + +@pytest.mark.e2e +async def test_given_jwt_missing_email_claim_when_getting_me_then_returns_401( + client: AsyncClient, + mint_token: Callable[..., str], +) -> None: + # GIVEN + token = mint_token("azure-sub-noprofile", email=None, name="Whoever") + + # WHEN + response = await client.get( + "/v1/users/me", headers={"Authorization": f"Bearer {token}"} + ) + + # THEN + assert response.status_code == 401 + + +@pytest.mark.e2e +async def test_given_jwt_missing_email_claim_when_getting_me_then_error_code_is_missing_profile_claims( + client: AsyncClient, + mint_token: Callable[..., str], +) -> None: + # GIVEN + token = mint_token("azure-sub-noprofile", email=None, name="Whoever") + + # WHEN + body = (await client.get( + "/v1/users/me", headers={"Authorization": f"Bearer {token}"} + )).json() + + # THEN + assert body["error"]["code"] == "AUTH_MISSING_PROFILE_CLAIMS" + + +@pytest.mark.e2e +async def test_given_same_jwt_when_calling_me_twice_then_user_is_provisioned_only_once( + client: AsyncClient, + mint_token: Callable[..., str], + e2e_session_factory: async_sessionmaker[AsyncSession], +) -> None: + # GIVEN + token = mint_token("azure-sub-idem", email="alice@example.com", name="Alice") + + # WHEN + await client.get("/v1/users/me", headers={"Authorization": f"Bearer {token}"}) + await client.get("/v1/users/me", headers={"Authorization": f"Bearer {token}"}) + + # THEN + async with e2e_session_factory() as session: + count = await session.scalar( + select(func.count()).select_from(UserModel).where(UserModel.subject == "azure-sub-idem") + ) + assert count == 1 diff --git a/{{cookiecutter.project_slug}}/tests/e2e/conftest.py b/{{cookiecutter.project_slug}}/tests/e2e/conftest.py new file mode 100644 index 0000000..70e2b83 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/e2e/conftest.py @@ -0,0 +1,162 @@ +"""E2E-layer conftest — FastAPI ``TestClient`` wired to REAL adapters. + +The ``app`` fixture overrides only what cannot be exercised end-to-end +without a self-hosted dependency: + +* ``get_session`` → a real ``AsyncSession`` bound to the testcontainers + Postgres engine, wrapped in an outer transaction that rolls back at + teardown (per-test isolation, same savepoint pattern as integration). +* ``get_token_verifier`` → a real ``JwtTokenVerifier`` configured for + ``HS256`` against a known test secret. Tests mint legitimate JWTs via + the ``mint_token`` fixture; the verifier checks the signature like in + production. We deliberately do not stand up Keycloak here — the bake- + time devcontainer Keycloak is for human dev, not for the test suite. + +The use case factory, repository factory, event bus factory, and every +other production-wired dependency are exercised as-is. The InMemory +Fakes from ``tests/fakes/`` stay reserved for unit tests. + +``StubAsyncSession`` is still exported for the single readiness-probe +test that simulates a DB outage by raising ``OperationalError`` on +``execute``. That use is intentional: we want to test the failure mode, +not a real DB outage. +""" + +from __future__ import annotations + +import time +from collections.abc import AsyncIterator, Callable + +import jwt +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from {{ cookiecutter.package_name }}.infrastructure.auth.jwt_verifier import JwtTokenVerifier +from {{ cookiecutter.package_name }}.presentation.api.app import create_app +from {{ cookiecutter.package_name }}.presentation.api.dependencies.auth import ( + get_token_verifier, +) +from {{ cookiecutter.package_name }}.presentation.api.dependencies.common import get_session + +E2E_JWT_SECRET = "test-secret-do-not-use-in-real-environments" + + +class StubAsyncSession: + """Minimal ``AsyncSession``-shaped double for the DB-outage readiness test. + + Pass ``raises=`` to simulate the database being unreachable. + """ + + def __init__(self, *, raises: Exception | None = None) -> None: + self._raises = raises + + async def execute(self, _statement: object) -> None: + if self._raises is not None: + raise self._raises + + +@pytest.fixture +def stub_session() -> StubAsyncSession: + return StubAsyncSession() + + +@pytest_asyncio.fixture +async def e2e_session_factory( + pg_engine: AsyncEngine, +) -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """A session factory bound to a connection wrapped in an outer transaction. + + Each request gets its own ``AsyncSession`` from this factory; the + route handler's ``session.begin()`` runs inside a SAVEPOINT thanks + to ``join_transaction_mode='create_savepoint'``. At teardown the + outer transaction is rolled back, wiping every write the test + performed — including those that were ``COMMIT``ed by routes. + """ + async with pg_engine.connect() as conn: + trans = await conn.begin() + factory = async_sessionmaker( + bind=conn, + class_=AsyncSession, + expire_on_commit=False, + join_transaction_mode="create_savepoint", + ) + try: + yield factory + finally: + await trans.rollback() + + +@pytest.fixture +def jwt_verifier() -> JwtTokenVerifier: + """Real HS256 verifier configured with the e2e test secret.""" + return JwtTokenVerifier(algorithm="HS256", secret=E2E_JWT_SECRET) + + +@pytest.fixture +def mint_token() -> Callable[..., str]: + """Return a function that mints a real HS256 JWT for the e2e suite. + + Signature: ``mint_token(subject, *, email='user@example.com', + name='User', roles=())``. Pass ``email=None`` or ``name=None`` to + simulate IdP tokens that omit those profile claims (Azure guest + accounts, custom scopes, …). + """ + + def _mint( + subject: str, + *, + email: str | None = "user@example.com", + name: str | None = "User", + roles: tuple[str, ...] = (), + ) -> str: + now = int(time.time()) + payload: dict[str, object] = {"sub": subject, "iat": now, "exp": now + 3600} + if email is not None: + payload["email"] = email + if name is not None: + payload["name"] = name + if roles: + payload["roles"] = list(roles) + return jwt.encode(payload, E2E_JWT_SECRET, algorithm="HS256") + + return _mint + + +@pytest.fixture +def app( + e2e_session_factory: async_sessionmaker[AsyncSession], + jwt_verifier: JwtTokenVerifier, +) -> FastAPI: + """FastAPI app with the real production wiring, minus DB + JWT seams.""" + app = create_app() + + async def _session_override() -> AsyncIterator[AsyncSession]: + async with e2e_session_factory() as session, session.begin(): + yield session + + app.dependency_overrides[get_session] = _session_override + app.dependency_overrides[get_token_verifier] = lambda: jwt_verifier + return app + + +@pytest_asyncio.fixture +async def client(app: FastAPI) -> AsyncIterator[AsyncClient]: + """``httpx.AsyncClient`` calling the ASGI app on the test's event loop. + + Using the async client (instead of starlette's sync ``TestClient``, + which runs the app in an anyio portal thread) keeps the request + handler on the same event loop as the test fixtures. That matters + because the session and connection in ``e2e_session_factory`` are + loop-bound — calling them from another loop raises + ``Future attached to a different loop``. + + The FastAPI lifespan is intentionally NOT entered: the production + lifespan builds a real config-driven engine and instruments + SQLAlchemy. Tests inject their own engine via the overrides above. + """ + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac diff --git a/{{cookiecutter.project_slug}}/tests/fakes/__init__.py b/{{cookiecutter.project_slug}}/tests/fakes/__init__.py new file mode 100644 index 0000000..d5748bf --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/fakes/__init__.py @@ -0,0 +1,5 @@ +"""In-memory implementations of application abstractions used in tests. + +The whole package is the contract: production code never imports from +``tests/fakes`` and tests never use ``unittest.mock`` for these collaborators. +""" diff --git a/{{cookiecutter.project_slug}}/tests/fakes/auth.py b/{{cookiecutter.project_slug}}/tests/fakes/auth.py new file mode 100644 index 0000000..2c55a9c --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/fakes/auth.py @@ -0,0 +1,46 @@ +"""Fake ``TokenVerifier`` for unit and e2e tests. + +The verifier maintains an in-memory map ``token -> CurrentUser``. Tests +register a token via :meth:`accept` and pass that token as the bearer. +Any other token raises ``InvalidTokenError``. The empty string is treated +as absent — use the no-header path for that. +""" + +from __future__ import annotations + +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser +from {{ cookiecutter.package_name }}.domain.exceptions.auth import InvalidTokenError + + +class FakeTokenVerifier: + """In-memory ``TokenVerifier`` for tests. Doubles as a Spy via ``.calls``.""" + + def __init__(self) -> None: + self._users: dict[str, CurrentUser] = {} + self.calls: list[str] = [] + + def accept( + self, + token: str, + *, + subject: str = "test-subject", + email: str | None = "test@example.com", + name: str | None = "Test User", + roles: tuple[str, ...] = (), + ) -> CurrentUser: + """Register ``token`` to resolve to a synthetic ``CurrentUser``. + + Returns the resolved ``CurrentUser`` so the test can refer to it for + assertions without re-instantiating. + """ + user = CurrentUser(subject=subject, email=email, name=name, roles=roles) + self._users[token] = user + return user + + def verify(self, token: str) -> CurrentUser: + """Look up ``token``; raise ``InvalidTokenError`` if not registered.""" + self.calls.append(token) + try: + return self._users[token] + except KeyError as exc: + raise InvalidTokenError(reason="unknown_token") from exc diff --git a/{{cookiecutter.project_slug}}/tests/fakes/clock.py b/{{cookiecutter.project_slug}}/tests/fakes/clock.py new file mode 100644 index 0000000..6e8c1d4 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/fakes/clock.py @@ -0,0 +1,20 @@ +"""Deterministic clock fake.""" + +from __future__ import annotations + +from datetime import datetime + +from {{ cookiecutter.package_name }}.application.clock import Clock + + +class FrozenClock(Clock): + """A clock that always returns the same instant unless ``set(...)`` is called.""" + + def __init__(self, at: datetime) -> None: + self._now = at + + def now(self) -> datetime: + return self._now + + def set(self, at: datetime) -> None: + self._now = at diff --git a/{{cookiecutter.project_slug}}/tests/fakes/event_bus.py b/{{cookiecutter.project_slug}}/tests/fakes/event_bus.py new file mode 100644 index 0000000..d0371a2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/fakes/event_bus.py @@ -0,0 +1,20 @@ +"""Fake ``EventBus`` for unit tests. + +Records every ``(event_name, payload)`` tuple in ``published``. Tests assert +on that list as the Spy view of "did the use case emit what it should". +""" + +from __future__ import annotations + +from typing import Any + + +class InMemoryEventBus: + """In-memory ``EventBus`` that records published events for assertion.""" + + def __init__(self) -> None: + self.published: list[tuple[str, dict[str, Any]]] = [] + + async def publish(self, event_name: str, payload: dict[str, Any]) -> None: + """Append ``(event_name, payload)`` to ``self.published``.""" + self.published.append((event_name, payload)) diff --git a/{{cookiecutter.project_slug}}/tests/fakes/id_generator.py b/{{cookiecutter.project_slug}}/tests/fakes/id_generator.py new file mode 100644 index 0000000..bc07429 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/fakes/id_generator.py @@ -0,0 +1,19 @@ +"""Deterministic id generator fake.""" + +from __future__ import annotations + +from uuid import UUID + +from {{ cookiecutter.package_name }}.application.id_generator import IdGenerator + + +class SequentialIdGenerator(IdGenerator): + """Yields predictable UUIDs ``00000000-0000-0000-0000-00000000000n``.""" + + def __init__(self, start: int = 1) -> None: + self._counter = start + + def new(self) -> UUID: + value = UUID(int=self._counter) + self._counter += 1 + return value diff --git a/{{cookiecutter.project_slug}}/tests/fakes/random_source.py b/{{cookiecutter.project_slug}}/tests/fakes/random_source.py new file mode 100644 index 0000000..b50bd15 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/fakes/random_source.py @@ -0,0 +1,22 @@ +"""Deterministic random source fake.""" + +from __future__ import annotations + +import random + +from {{ cookiecutter.package_name }}.application.random_source import RandomSource + + +class SeededRandomSource(RandomSource): + """Random source backed by ``random.Random(seed)`` for repeatable tests.""" + + def __init__(self, seed: int = 0) -> None: + self._rng = random.Random(seed) # noqa: S311 — non-cryptographic on purpose for tests + + def next_int(self, low: int, high: int) -> int: + if low > high: + raise ValueError(f"low ({low}) must be <= high ({high})") + return self._rng.randint(low, high) + + def next_float(self) -> float: + return self._rng.random() diff --git a/{{cookiecutter.project_slug}}/tests/fakes/repositories/__init__.py b/{{cookiecutter.project_slug}}/tests/fakes/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/fakes/repositories/user.py b/{{cookiecutter.project_slug}}/tests/fakes/repositories/user.py new file mode 100644 index 0000000..f7cf5d4 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/fakes/repositories/user.py @@ -0,0 +1,38 @@ +"""In-memory ``UserRepository`` for unit tests.""" + +from __future__ import annotations + +from collections.abc import Iterable + +from {{ cookiecutter.package_name }}.application.repositories.user import UserRepository +from {{ cookiecutter.package_name }}.domain.entities.user import User +from {{ cookiecutter.package_name }}.domain.exceptions.user import UserAlreadyExistsError +from {{ cookiecutter.package_name }}.domain.value_objects.email import Email +from {{ cookiecutter.package_name }}.domain.value_objects.user_id import UserId + + +class InMemoryUserRepository(UserRepository): + """Dict-backed user repository. + + Tests can also inspect ``self.users`` to assert side-effects, exactly the + role a Spy would play, without pulling in ``unittest.mock``. + """ + + def __init__(self, seed: Iterable[User] = ()) -> None: + self.users: dict[UserId, User] = {u.id: u for u in seed} + + async def add(self, user: User) -> None: + if any(u.subject == user.subject for u in self.users.values()): + raise UserAlreadyExistsError(subject=user.subject) + if any(u.email == user.email for u in self.users.values()): + raise UserAlreadyExistsError(email=str(user.email)) + self.users[user.id] = user + + async def find_by_subject(self, subject: str) -> User | None: + return next((u for u in self.users.values() if u.subject == subject), None) + + async def find_by_email(self, email: Email) -> User | None: + return next((u for u in self.users.values() if u.email == email), None) + + async def find_by_id(self, id: UserId) -> User | None: + return self.users.get(id) diff --git a/{{cookiecutter.project_slug}}/tests/integration/__init__.py b/{{cookiecutter.project_slug}}/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/integration/conftest.py b/{{cookiecutter.project_slug}}/tests/integration/conftest.py new file mode 100644 index 0000000..8b60c86 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/integration/conftest.py @@ -0,0 +1,46 @@ +"""Integration-layer conftest — clean session per test on testcontainers PG. + +``pg_url`` and ``pg_engine`` (session-scoped) live in ``tests/conftest.py`` +because both integration and e2e consume them. This module owns the +per-test transaction-wrapped session that integration tests use. + +When Docker is not reachable, the root ``pg_url`` fixture skips with a +clear message; this conftest only adds the test-scoped session on top. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + + +@pytest_asyncio.fixture +async def pg_session(pg_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: + """Yield a session inside a transaction that rolls back on exit. + + Mirrors the request-boundary semantics of ADR 0004: the test sees a + real session with a real transaction; integrity errors surface as + they would in production; the rollback at exit makes the next test + start from a clean slate. + + ``join_transaction_mode='create_savepoint'`` makes the session open + a SAVEPOINT for its writes inside the fixture's outer transaction. + An ``IntegrityError`` then rolls back only the savepoint, leaving + the outer transaction alive so the teardown rollback works without + raising ``SAWarning: transaction already deassociated from connection``. + """ + async with pg_engine.connect() as conn: + trans = await conn.begin() + factory = async_sessionmaker( + bind=conn, + class_=AsyncSession, + expire_on_commit=False, + join_transaction_mode="create_savepoint", + ) + async with factory() as session: + try: + yield session + finally: + await trans.rollback() diff --git a/{{cookiecutter.project_slug}}/tests/integration/infrastructure/__init__.py b/{{cookiecutter.project_slug}}/tests/integration/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/integration/infrastructure/auth/__init__.py b/{{cookiecutter.project_slug}}/tests/integration/infrastructure/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/integration/infrastructure/auth/test_jwt_verifier.py b/{{cookiecutter.project_slug}}/tests/integration/infrastructure/auth/test_jwt_verifier.py new file mode 100644 index 0000000..c6cdbb0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/integration/infrastructure/auth/test_jwt_verifier.py @@ -0,0 +1,195 @@ +"""Integration tests for :class:`JwtTokenVerifier` — round-trip with PyJWT. + +Pure stdlib + PyJWT — no testcontainers, but lives in ``integration/`` +because it exercises a real crypto library, not a Fake. Each test signs a +JWT then verifies it through the production binding. + +The RS256 branch is special: ``PyJWKClient`` (the JWKS fetcher shipped +by PyJWT) uses ``urllib`` internally, NOT ``httpx``, so ``respx`` +cannot intercept its requests. The right tool here is monkeypatching +``PyJWKClient.fetch_data``. For our own httpx-based outbound adapters, +``respx`` remains the canonical pattern (see the +``building-a-feature`` skill, §"Testing external HTTP adapters"). +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from typing import Any + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt import PyJWKClient + +from {{ cookiecutter.package_name }}.domain.exceptions.auth import InvalidTokenError +from {{ cookiecutter.package_name }}.infrastructure.auth.jwt_verifier import JwtTokenVerifier + +SECRET = "test-secret-do-not-use-in-real-environments" # >=32 bytes for HMAC-SHA256 +JWKS_URL = "https://idp.example.com/.well-known/jwks.json" +KID = "test-kid" + + +def _sign(payload: dict[str, Any], *, secret: str = SECRET) -> str: + return jwt.encode(payload, secret, algorithm="HS256") + + +def _rsa_keypair() -> rsa.RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _jwks_payload(private_key: rsa.RSAPrivateKey) -> dict[str, Any]: + jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(private_key.public_key())) + jwk.update({"kid": KID, "alg": "RS256", "use": "sig"}) + return {"keys": [jwk]} + + +def _sign_rs256(payload: dict[str, Any], *, private_key: rsa.RSAPrivateKey) -> str: + return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": KID}) + + +def _claims( + *, + sub: str = "alice", + exp_offset_seconds: int = 60, + aud: str | None = None, + iss: str | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "sub": sub, + "exp": datetime.now(tz=UTC) + timedelta(seconds=exp_offset_seconds), + } + if aud is not None: + payload["aud"] = aud + if iss is not None: + payload["iss"] = iss + if extra: + payload.update(extra) + return payload + + +@pytest.mark.integration +def test_given_valid_hs256_token_when_verifying_then_returns_current_user() -> None: + # GIVEN + verifier = JwtTokenVerifier(algorithm="HS256", secret=SECRET) + token = _sign(_claims(extra={"email": "alice@example.com", "roles": ["admin"]})) + + # WHEN + user = verifier.verify(token) + + # THEN + assert (user.subject, user.email, user.roles) == ("alice", "alice@example.com", ("admin",)) + + +@pytest.mark.integration +def test_given_expired_token_when_verifying_then_raises_invalid_token() -> None: + # GIVEN + verifier = JwtTokenVerifier(algorithm="HS256", secret=SECRET) + token = _sign(_claims(exp_offset_seconds=-1)) + + # WHEN / THEN + with pytest.raises(InvalidTokenError): + verifier.verify(token) + + +@pytest.mark.integration +def test_given_token_signed_with_wrong_secret_when_verifying_then_raises_invalid_token() -> None: + # GIVEN + verifier = JwtTokenVerifier(algorithm="HS256", secret=SECRET) + token = _sign(_claims(), secret="another-secret-also-long-enough-for-hmac-sha256") + + # WHEN / THEN + with pytest.raises(InvalidTokenError): + verifier.verify(token) + + +@pytest.mark.integration +def test_given_token_missing_sub_claim_when_verifying_then_raises_invalid_token() -> None: + # GIVEN + verifier = JwtTokenVerifier(algorithm="HS256", secret=SECRET) + payload = _claims() + del payload["sub"] + token = _sign(payload) + + # WHEN / THEN + with pytest.raises(InvalidTokenError): + verifier.verify(token) + + +@pytest.mark.integration +def test_given_audience_mismatch_when_verifying_then_raises_invalid_token() -> None: + # GIVEN + verifier = JwtTokenVerifier(algorithm="HS256", secret=SECRET, audience="expected-aud") + token = _sign(_claims(aud="other-aud")) + + # WHEN / THEN + with pytest.raises(InvalidTokenError): + verifier.verify(token) + + +@pytest.mark.integration +def test_given_keycloak_style_roles_claim_path_when_verifying_then_extracts_roles() -> None: + # GIVEN + verifier = JwtTokenVerifier( + algorithm="HS256", + secret=SECRET, + roles_claim="realm_access.roles", + ) + token = _sign( + _claims(extra={"realm_access": {"roles": ["admin", "user"]}}), + ) + + # WHEN + user = verifier.verify(token) + + # THEN + assert user.roles == ("admin", "user") + + +@pytest.mark.integration +def test_given_missing_roles_path_when_verifying_then_returns_empty_roles() -> None: + # GIVEN + verifier = JwtTokenVerifier(algorithm="HS256", secret=SECRET, roles_claim="realm_access.roles") + token = _sign(_claims()) # no realm_access in payload + + # WHEN + user = verifier.verify(token) + + # THEN + assert user.roles == () + + +@pytest.mark.integration +def test_given_token_with_name_claim_when_verifying_then_extracts_name() -> None: + # GIVEN + verifier = JwtTokenVerifier(algorithm="HS256", secret=SECRET) + token = _sign(_claims(extra={"name": "Alice Liddell"})) + + # WHEN + user = verifier.verify(token) + + # THEN + assert user.name == "Alice Liddell" + + +@pytest.mark.integration +def test_given_valid_rs256_token_when_verifying_then_fetches_jwks_and_returns_current_user( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # GIVEN: the IdP serves a JWKS containing our test public key + private_key = _rsa_keypair() + jwks = _jwks_payload(private_key) + monkeypatch.setattr(PyJWKClient, "fetch_data", lambda _self: jwks) + verifier = JwtTokenVerifier(algorithm="RS256", jwks_url=JWKS_URL) + token = _sign_rs256( + _claims(extra={"email": "alice@example.com", "name": "Alice Liddell"}), + private_key=private_key, + ) + + # WHEN + user = verifier.verify(token) + + # THEN + assert (user.subject, user.email, user.name) == ("alice", "alice@example.com", "Alice Liddell") diff --git a/{{cookiecutter.project_slug}}/tests/integration/infrastructure/test_user_repository.py b/{{cookiecutter.project_slug}}/tests/integration/infrastructure/test_user_repository.py new file mode 100644 index 0000000..dcb9944 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/integration/infrastructure/test_user_repository.py @@ -0,0 +1,166 @@ +"""Integration tests for ``SqlAlchemyUserRepository``. + +These tests exercise the real SQLAlchemy code path against a fresh +Postgres 17 container. They prove the contract that unit tests with the +``InMemoryUserRepository`` Fake cannot: + +* INSERT propagates the unique constraint violation as + ``UserAlreadyExistsError`` (the integrity-error → domain-error + translation lives in the production binding). +* ``find_by_subject`` / ``find_by_email`` / ``find_by_id`` round-trip the + model ↔ entity mapping correctly (the unit-level Fake bypasses that + layer). +* The transactional behaviour matches what the request boundary does + in production (ADR 0004): ``flush`` surfaces violations early, the + outer transaction commits or rolls back atomically. + +One test = one edge case. No ``@parametrize``: each scenario is named +and written to fail first (TDD) before the corresponding production +code lands. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from {{ cookiecutter.package_name }}.domain.entities.user import User +from {{ cookiecutter.package_name }}.domain.exceptions.user import UserAlreadyExistsError +from {{ cookiecutter.package_name }}.domain.value_objects.email import Email +from {{ cookiecutter.package_name }}.domain.value_objects.user_id import UserId +from {{ cookiecutter.package_name }}.infrastructure.persistence.user_repository import ( + SqlAlchemyUserRepository, +) + + +def _user( + *, + uid: int = 1, + subject: str = "azure-sub-1", + email: str = "alice@example.com", + name: str = "Alice", +) -> User: + return User( + id=UserId(UUID(int=uid)), + subject=subject, + email=Email(email), + name=name, + created_at=datetime(2026, 5, 5, 12, 0, tzinfo=UTC), + ) + + +@pytest.mark.integration +async def test_given_empty_db_when_adding_user_then_find_by_id_round_trips_subject( + pg_session: AsyncSession, +) -> None: + # GIVEN + repo = SqlAlchemyUserRepository(pg_session) + await repo.add(_user(uid=1, subject="azure-sub-1")) + + # WHEN + found = await repo.find_by_id(UserId(UUID(int=1))) + + # THEN + assert (found and found.subject) == "azure-sub-1" + + +@pytest.mark.integration +async def test_given_persisted_user_when_finding_by_subject_then_returns_matching_id( + pg_session: AsyncSession, +) -> None: + # GIVEN + repo = SqlAlchemyUserRepository(pg_session) + await repo.add(_user(uid=1, subject="azure-sub-42")) + + # WHEN + found = await repo.find_by_subject("azure-sub-42") + + # THEN + assert (found and found.id) == UserId(UUID(int=1)) + + +@pytest.mark.integration +async def test_given_unknown_subject_when_finding_by_subject_then_returns_none( + pg_session: AsyncSession, +) -> None: + # GIVEN + repo = SqlAlchemyUserRepository(pg_session) + + # WHEN + found = await repo.find_by_subject("ghost-sub") + + # THEN + assert found is None + + +@pytest.mark.integration +async def test_given_duplicate_subject_when_adding_then_raises_already_exists( + pg_session: AsyncSession, +) -> None: + # GIVEN + repo = SqlAlchemyUserRepository(pg_session) + await repo.add(_user(uid=1, subject="azure-sub-x", email="alice@example.com")) + + # WHEN / THEN + with pytest.raises(UserAlreadyExistsError) as excinfo: + await repo.add(_user(uid=2, subject="azure-sub-x", email="bob@example.com")) + assert excinfo.value.code == "USER_ALREADY_EXISTS" + + +@pytest.mark.integration +async def test_given_duplicate_email_when_adding_then_raises_already_exists( + pg_session: AsyncSession, +) -> None: + # GIVEN + repo = SqlAlchemyUserRepository(pg_session) + await repo.add(_user(uid=1, subject="azure-sub-1", email="alice@example.com")) + + # WHEN / THEN + with pytest.raises(UserAlreadyExistsError): + await repo.add(_user(uid=2, subject="azure-sub-2", email="alice@example.com")) + + +@pytest.mark.integration +async def test_given_persisted_user_when_finding_by_email_then_returns_user( + pg_session: AsyncSession, +) -> None: + # GIVEN + repo = SqlAlchemyUserRepository(pg_session) + await repo.add(_user(email="alice@example.com")) + + # WHEN + found = await repo.find_by_email(Email("alice@example.com")) + + # THEN + assert found is not None + + +@pytest.mark.integration +async def test_given_unknown_email_when_finding_by_email_then_returns_none( + pg_session: AsyncSession, +) -> None: + # GIVEN + repo = SqlAlchemyUserRepository(pg_session) + + # WHEN + found = await repo.find_by_email(Email("ghost@example.com")) + + # THEN + assert found is None + + +@pytest.mark.integration +async def test_given_unknown_id_when_finding_by_id_then_returns_none( + pg_session: AsyncSession, +) -> None: + # GIVEN + repo = SqlAlchemyUserRepository(pg_session) + + # WHEN + found = await repo.find_by_id(UserId(UUID(int=999))) + + # THEN + assert found is None diff --git a/{{cookiecutter.project_slug}}/tests/integration/jobs/__init__.py b/{{cookiecutter.project_slug}}/tests/integration/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/integration/jobs/test_outbox_relay.py b/{{cookiecutter.project_slug}}/tests/integration/jobs/test_outbox_relay.py new file mode 100644 index 0000000..7130e28 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/integration/jobs/test_outbox_relay.py @@ -0,0 +1,502 @@ +"""Integration tests for the outbox relay roundtrip. + +These tests prove the full chain works against a real Postgres: + + publish to SqlOutboxEventBus → outbox_events row committed + → _drain_once picks the row (status='pending', due) + → registered in-process handler runs + → status transitions to 'published' / 'poisoned' according to outcome + +Pure GIVEN/WHEN/THEN, one edge case per test, no parametrize. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from {{ cookiecutter.package_name }}.infrastructure.jobs.backoff import MAX_ATTEMPTS +from {{ cookiecutter.package_name }}.infrastructure.jobs.handlers import _HANDLERS, handler +from {{ cookiecutter.package_name }}.infrastructure.jobs.outbox_relay import _drain_once +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.outbox_event import OutboxEventModel + + +@pytest.fixture +def isolated_handlers() -> list[str]: + """Snapshot the handler registry, yield, restore. + + The integration tests register their own handlers; we restore the + original registry afterwards so subsequent tests are not polluted. + """ + saved = {k: list(v) for k, v in _HANDLERS.items()} + _HANDLERS.clear() + yield list(saved.keys()) + _HANDLERS.clear() + for k, v in saved.items(): + _HANDLERS[k] = v + + +def _row(**overrides: object) -> OutboxEventModel: + """Build an outbox row with sane defaults; tests override what they exercise.""" + base: dict[str, object] = { + "id": uuid4(), + "event_name": "user.created", + "payload": {"user_id": str(UUID(int=42))}, + "created_at": datetime.now(tz=UTC), + } + base.update(overrides) + return OutboxEventModel(**base) # type: ignore[arg-type] + + +async def _refresh(pg_session: AsyncSession, row_id: UUID) -> OutboxEventModel: + return (await pg_session.execute(select(OutboxEventModel).where(OutboxEventModel.id == row_id))).scalar_one() + + +@pytest.mark.integration +async def test_given_outbox_row_when_draining_then_drain_returns_one_published( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def noop(payload: dict) -> None: + return None + + pg_session.add(_row()) + await pg_session.flush() + + # WHEN + published = await _drain_once(pg_session) + + # THEN + assert published == 1 + + +@pytest.mark.integration +async def test_given_outbox_row_when_draining_then_handler_receives_the_payload( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + received: list[dict] = [] + + @handler("user.created") + async def collect(payload: dict) -> None: + received.append(payload) + + pg_session.add(_row(payload={"user_id": str(UUID(int=42)), "email": "alice@example.com"})) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + + # THEN + assert received == [{"user_id": str(UUID(int=42)), "email": "alice@example.com"}] + + +@pytest.mark.integration +async def test_given_outbox_row_when_draining_then_row_reaches_published_terminal_state( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def noop(payload: dict) -> None: + return None + + row = _row() + pg_session.add(row) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + refreshed = await _refresh(pg_session, row.id) + + # THEN: terminal state is one observable (status + published_at marker + attempts) + assert (refreshed.status, refreshed.published_at is not None, refreshed.attempts) == ( + "published", True, 0, + ) + + +@pytest.mark.integration +async def test_given_handler_raises_when_draining_then_drain_returns_zero( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def boom(payload: dict) -> None: + raise RuntimeError("downstream outage") + + pg_session.add(_row()) + await pg_session.flush() + + # WHEN + published = await _drain_once(pg_session) + + # THEN + assert published == 0 + + +@pytest.mark.integration +async def test_given_handler_raises_when_draining_then_row_stays_pending_with_retry_metadata( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def boom(payload: dict) -> None: + raise RuntimeError("downstream outage") + + row = _row() + pg_session.add(row) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + refreshed = await _refresh(pg_session, row.id) + + # THEN: failure state is one observable (status + counters + retry markers) + assert ( + refreshed.status, + refreshed.published_at, + refreshed.attempts, + refreshed.last_attempt_at is not None, + refreshed.next_attempt_at is not None, + ) == ("pending", None, 1, True, True) + + +@pytest.mark.integration +async def test_given_handler_raises_when_draining_then_row_captures_the_error_message( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def boom(payload: dict) -> None: + raise RuntimeError("downstream outage") + + row = _row() + pg_session.add(row) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + refreshed = await _refresh(pg_session, row.id) + + # THEN + assert "downstream outage" in (refreshed.last_error or "") + + +@pytest.mark.integration +async def test_given_no_pending_rows_when_draining_then_returns_zero( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN: no rows inserted + + # WHEN + published = await _drain_once(pg_session) + + # THEN + assert published == 0 + + +@pytest.mark.integration +async def test_given_already_published_row_when_draining_then_drain_returns_zero( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def noop(payload: dict) -> None: + return None + + pg_session.add(_row(published_at=datetime.now(tz=UTC), status="published")) + await pg_session.flush() + + # WHEN + published = await _drain_once(pg_session) + + # THEN + assert published == 0 + + +@pytest.mark.integration +async def test_given_already_published_row_when_draining_then_handler_is_not_invoked( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + received: list[dict] = [] + + @handler("user.created") + async def collect(payload: dict) -> None: + received.append(payload) + + pg_session.add(_row(published_at=datetime.now(tz=UTC), status="published")) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + + # THEN + assert received == [] + + +@pytest.mark.integration +async def test_given_row_with_future_next_attempt_when_draining_then_drain_returns_zero( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def noop(payload: dict) -> None: + return None + + pg_session.add(_row( + attempts=2, + last_attempt_at=datetime.now(tz=UTC), + next_attempt_at=datetime.now(tz=UTC) + timedelta(minutes=5), + )) + await pg_session.flush() + + # WHEN + published = await _drain_once(pg_session) + + # THEN + assert published == 0 + + +@pytest.mark.integration +async def test_given_row_with_future_next_attempt_when_draining_then_handler_is_not_invoked( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + received: list[dict] = [] + + @handler("user.created") + async def collect(payload: dict) -> None: + received.append(payload) + + pg_session.add(_row( + attempts=2, + last_attempt_at=datetime.now(tz=UTC), + next_attempt_at=datetime.now(tz=UTC) + timedelta(minutes=5), + )) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + + # THEN + assert received == [] + + +@pytest.mark.integration +async def test_given_row_with_future_next_attempt_when_draining_then_row_is_unchanged( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def noop(payload: dict) -> None: + return None + + row = _row( + attempts=2, + last_attempt_at=datetime.now(tz=UTC), + next_attempt_at=datetime.now(tz=UTC) + timedelta(minutes=5), + ) + pg_session.add(row) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + refreshed = await _refresh(pg_session, row.id) + + # THEN: still pending, attempts counter not incremented + assert (refreshed.status, refreshed.attempts) == ("pending", 2) + + +@pytest.mark.integration +async def test_given_row_with_due_next_attempt_when_draining_then_drain_returns_one( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def noop(payload: dict) -> None: + return None + + pg_session.add(_row( + attempts=2, + last_attempt_at=datetime.now(tz=UTC) - timedelta(minutes=10), + next_attempt_at=datetime.now(tz=UTC) - timedelta(seconds=1), + )) + await pg_session.flush() + + # WHEN + published = await _drain_once(pg_session) + + # THEN + assert published == 1 + + +@pytest.mark.integration +async def test_given_row_with_due_next_attempt_when_draining_then_handler_runs_once( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + received: list[dict] = [] + + @handler("user.created") + async def collect(payload: dict) -> None: + received.append(payload) + + pg_session.add(_row( + attempts=2, + last_attempt_at=datetime.now(tz=UTC) - timedelta(minutes=10), + next_attempt_at=datetime.now(tz=UTC) - timedelta(seconds=1), + )) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + + # THEN + assert len(received) == 1 + + +@pytest.mark.integration +async def test_given_row_with_due_next_attempt_when_draining_then_row_becomes_published( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def noop(payload: dict) -> None: + return None + + row = _row( + attempts=2, + last_attempt_at=datetime.now(tz=UTC) - timedelta(minutes=10), + next_attempt_at=datetime.now(tz=UTC) - timedelta(seconds=1), + ) + pg_session.add(row) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + refreshed = await _refresh(pg_session, row.id) + + # THEN + assert refreshed.status == "published" + + +@pytest.mark.integration +async def test_given_row_reaches_max_attempts_when_handler_fails_then_drain_returns_zero( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def boom(payload: dict) -> None: + raise RuntimeError("permanently broken") + + pg_session.add(_row( + attempts=MAX_ATTEMPTS - 1, + last_attempt_at=datetime.now(tz=UTC) - timedelta(hours=1), + next_attempt_at=datetime.now(tz=UTC) - timedelta(seconds=1), + )) + await pg_session.flush() + + # WHEN + published = await _drain_once(pg_session) + + # THEN + assert published == 0 + + +@pytest.mark.integration +async def test_given_row_reaches_max_attempts_when_handler_fails_then_row_is_poisoned( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def boom(payload: dict) -> None: + raise RuntimeError("permanently broken") + + row = _row( + attempts=MAX_ATTEMPTS - 1, + last_attempt_at=datetime.now(tz=UTC) - timedelta(hours=1), + next_attempt_at=datetime.now(tz=UTC) - timedelta(seconds=1), + ) + pg_session.add(row) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + refreshed = await _refresh(pg_session, row.id) + + # THEN: poisoned terminal state (status + attempts at cap + no further retry) + assert (refreshed.status, refreshed.attempts, refreshed.next_attempt_at) == ( + "poisoned", MAX_ATTEMPTS, None, + ) + + +@pytest.mark.integration +async def test_given_poisoned_row_when_draining_then_drain_returns_zero( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + @handler("user.created") + async def noop(payload: dict) -> None: + return None + + pg_session.add(_row( + attempts=MAX_ATTEMPTS, + last_attempt_at=datetime.now(tz=UTC) - timedelta(hours=1), + status="poisoned", + )) + await pg_session.flush() + + # WHEN + published = await _drain_once(pg_session) + + # THEN + assert published == 0 + + +@pytest.mark.integration +async def test_given_poisoned_row_when_draining_then_handler_is_not_invoked( + pg_session: AsyncSession, + isolated_handlers: list[str], +) -> None: + # GIVEN + received: list[dict] = [] + + @handler("user.created") + async def collect(payload: dict) -> None: + received.append(payload) + + pg_session.add(_row( + attempts=MAX_ATTEMPTS, + last_attempt_at=datetime.now(tz=UTC) - timedelta(hours=1), + status="poisoned", + )) + await pg_session.flush() + + # WHEN + await _drain_once(pg_session) + + # THEN + assert received == [] diff --git a/{{cookiecutter.project_slug}}/tests/integration/middleware/__init__.py b/{{cookiecutter.project_slug}}/tests/integration/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/integration/middleware/test_idempotency.py b/{{cookiecutter.project_slug}}/tests/integration/middleware/test_idempotency.py new file mode 100644 index 0000000..467d0a5 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/integration/middleware/test_idempotency.py @@ -0,0 +1,392 @@ +"""Integration tests for :class:`IdempotencyMiddleware`. + +These tests wire a tiny FastAPI app whose ``state.session_factory`` points +at the testcontainers Postgres engine; both the middleware AND the test +setup use that same factory so the middleware's writes are immediately +visible to the assertions. We deliberately don't use the +``join_transaction_mode='create_savepoint'`` ``pg_session`` fixture here: +the middleware opens its own real transaction, which is incompatible with +the savepoint trick. + +We also drive the app with ``httpx.AsyncClient`` + ``ASGITransport`` +rather than ``TestClient``: the latter spawns an anyio BlockingPortal +that runs the request in a separate event loop, which breaks the +session-scoped asyncpg engine's connection pool. + +A ``_clean`` autouse fixture wipes ``idempotency_records`` around each +test so keys never leak. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from {{ cookiecutter.package_name }}.infrastructure.persistence.models.idempotency_record import IdempotencyRecord +from {{ cookiecutter.package_name }}.presentation.api.middleware.idempotency import IdempotencyMiddleware + + +@pytest.fixture +def session_factory(pg_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + """A session factory bound to the testcontainers engine (real commits).""" + return async_sessionmaker(bind=pg_engine, class_=AsyncSession, expire_on_commit=False) + + +@pytest.fixture(autouse=True) +async def _clean(session_factory: async_sessionmaker[AsyncSession]) -> AsyncIterator[None]: + """Wipe idempotency_records around each test so keys never leak.""" + async with session_factory() as s: + await s.execute(delete(IdempotencyRecord)) + await s.commit() + yield + async with session_factory() as s: + await s.execute(delete(IdempotencyRecord)) + await s.commit() + + +@pytest.fixture +def calls() -> list[str]: + return [] + + +@pytest.fixture +def app(session_factory: async_sessionmaker[AsyncSession], calls: list[str]) -> FastAPI: + """Tiny FastAPI app with the middleware wired and two counter endpoints.""" + app = FastAPI() + app.state.session_factory = session_factory + app.add_middleware(IdempotencyMiddleware, methods=frozenset({"POST"}), ttl_seconds=3600) + + @app.post("/echo") + async def echo(payload: dict) -> dict: + calls.append("echo") + return {"received": payload, "call_number": len(calls)} + + @app.post("/other") + async def other() -> dict: + calls.append("other") + return {"endpoint": "other"} + + return app + + +@pytest.fixture +async def client(app: FastAPI) -> AsyncIterator[AsyncClient]: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + + +async def _row_for(session_factory: async_sessionmaker[AsyncSession], key: str) -> IdempotencyRecord | None: + async with session_factory() as s: + return await s.scalar(select(IdempotencyRecord).where(IdempotencyRecord.key == key)) + + +async def _insert(session_factory: async_sessionmaker[AsyncSession], row: IdempotencyRecord) -> None: + async with session_factory() as s: + s.add(row) + await s.commit() + + +@pytest.mark.integration +async def test_given_no_header_when_posting_then_response_is_200_with_call_number_one( + client: AsyncClient, +) -> None: + # GIVEN: no Idempotency-Key header + + # WHEN + response = await client.post("/echo", json={"value": 1}) + + # THEN: status + body field describe the same response outcome (§4b) + assert (response.status_code, response.json()["call_number"]) == (200, 1) + + +@pytest.mark.integration +async def test_given_no_header_when_posting_then_handler_runs_once( + client: AsyncClient, + calls: list[str], +) -> None: + # GIVEN: no Idempotency-Key header + + # WHEN + await client.post("/echo", json={"value": 1}) + + # THEN + assert calls == ["echo"] + + +@pytest.mark.integration +async def test_given_no_header_when_posting_then_no_idempotency_row_is_persisted( + client: AsyncClient, + session_factory: async_sessionmaker[AsyncSession], +) -> None: + # GIVEN: no Idempotency-Key header + + # WHEN + await client.post("/echo", json={"value": 1}) + + # THEN + async with session_factory() as s: + count = await s.scalar(select(func.count()).select_from(IdempotencyRecord)) + assert count == 0 + + +@pytest.mark.integration +async def test_given_first_request_when_retried_with_same_key_then_both_responses_match( + client: AsyncClient, +) -> None: + # GIVEN + key = str(uuid4()) + + # WHEN + first = await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + second = await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + + # THEN: same (status, body) tuple on both attempts — one response observable + assert (first.status_code, first.json()) == (second.status_code, second.json()) + + +@pytest.mark.integration +async def test_given_first_request_when_retried_with_same_key_then_handler_runs_exactly_once( + client: AsyncClient, + calls: list[str], +) -> None: + # GIVEN + key = str(uuid4()) + + # WHEN + await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + + # THEN + assert calls == ["echo"] + + +@pytest.mark.integration +async def test_given_first_request_when_retried_with_same_key_then_row_reaches_completed_with_status_200( + client: AsyncClient, + session_factory: async_sessionmaker[AsyncSession], +) -> None: + # GIVEN + key = str(uuid4()) + + # WHEN + await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + row = await _row_for(session_factory, key) + + # THEN: terminal row state is one observable (status + response_status) + assert (row and (row.status, row.response_status)) == ("completed", 200) + + +@pytest.mark.integration +async def test_given_completed_key_when_retried_on_different_path_then_response_is_422_with_mismatch_code( + client: AsyncClient, +) -> None: + # GIVEN + key = str(uuid4()) + await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + + # WHEN + response = await client.post("/other", headers={"Idempotency-Key": key}) + + # THEN + assert (response.status_code, response.json()["error"]["code"]) == ( + 422, + "IDEMPOTENCY_KEY_MISMATCH", + ) + + +@pytest.mark.integration +async def test_given_completed_key_when_retried_on_different_path_then_other_handler_does_not_run( + client: AsyncClient, + calls: list[str], +) -> None: + # GIVEN + key = str(uuid4()) + await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + + # WHEN + await client.post("/other", headers={"Idempotency-Key": key}) + + # THEN: /other handler must not have been called + assert calls == ["echo"] + + +@pytest.mark.integration +async def test_given_in_progress_key_when_retried_then_response_is_409_with_in_progress_code( + client: AsyncClient, + session_factory: async_sessionmaker[AsyncSession], +) -> None: + # GIVEN: a row sitting in 'in_progress' (a previous attempt crashed + # between INSERT and UPDATE; the row stays in_progress until TTL). + key = str(uuid4()) + now = datetime.now(tz=UTC) + await _insert( + session_factory, + IdempotencyRecord( + key=key, + status="in_progress", + method="POST", + path="/echo", + created_at=now, + expires_at=now + timedelta(hours=1), + ), + ) + + # WHEN + response = await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + + # THEN + assert (response.status_code, response.json()["error"]["code"]) == ( + 409, + "IDEMPOTENCY_IN_PROGRESS", + ) + + +@pytest.mark.integration +async def test_given_expired_key_when_retried_then_response_is_fresh_not_stale_body( + client: AsyncClient, + session_factory: async_sessionmaker[AsyncSession], +) -> None: + # GIVEN: a completed row whose TTL has already elapsed + key = str(uuid4()) + now = datetime.now(tz=UTC) + await _insert( + session_factory, + IdempotencyRecord( + key=key, + status="completed", + method="POST", + path="/echo", + response_status=200, + response_body={"old": True}, + response_headers={}, + created_at=now - timedelta(hours=25), + completed_at=now - timedelta(hours=25), + expires_at=now - timedelta(seconds=1), + ), + ) + + # WHEN + response = await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + + # THEN: the stale {"old": True} body must not be returned + assert (response.status_code, "old" in response.json()) == (200, False) + + +@pytest.mark.integration +async def test_given_expired_key_when_retried_then_handler_runs_again( + client: AsyncClient, + session_factory: async_sessionmaker[AsyncSession], + calls: list[str], +) -> None: + # GIVEN: a completed row whose TTL has already elapsed + key = str(uuid4()) + now = datetime.now(tz=UTC) + await _insert( + session_factory, + IdempotencyRecord( + key=key, + status="completed", + method="POST", + path="/echo", + response_status=200, + response_body={"old": True}, + response_headers={}, + created_at=now - timedelta(hours=25), + completed_at=now - timedelta(hours=25), + expires_at=now - timedelta(seconds=1), + ), + ) + + # WHEN + await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + + # THEN + assert calls == ["echo"] + + +@pytest.mark.integration +async def test_given_expired_key_when_retried_then_row_is_refreshed_with_new_response( + client: AsyncClient, + session_factory: async_sessionmaker[AsyncSession], +) -> None: + # GIVEN: a completed row whose TTL has already elapsed + key = str(uuid4()) + now = datetime.now(tz=UTC) + await _insert( + session_factory, + IdempotencyRecord( + key=key, + status="completed", + method="POST", + path="/echo", + response_status=200, + response_body={"old": True}, + response_headers={}, + created_at=now - timedelta(hours=25), + completed_at=now - timedelta(hours=25), + expires_at=now - timedelta(seconds=1), + ), + ) + + # WHEN + response = await client.post("/echo", json={"value": 1}, headers={"Idempotency-Key": key}) + row = await _row_for(session_factory, key) + + # THEN: row reset to the new response (status + body must match the live response) + assert (row and (row.status, row.response_body)) == ("completed", response.json()) + + +@pytest.mark.integration +async def test_given_get_request_with_header_when_called_twice_then_handler_runs_each_time( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + # GIVEN: middleware configured for POST only; a GET endpoint hit twice with the same key + counter = {"hits": 0} + app = FastAPI() + app.state.session_factory = session_factory + app.add_middleware(IdempotencyMiddleware, methods=frozenset({"POST"}), ttl_seconds=3600) + + @app.get("/snapshot") + async def snapshot() -> dict: + counter["hits"] += 1 + return {"hits": counter["hits"]} + + key = str(uuid4()) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # WHEN + first = await client.get("/snapshot", headers={"Idempotency-Key": key}) + second = await client.get("/snapshot", headers={"Idempotency-Key": key}) + + # THEN: counter increments each call (one observable: the response body sequence) + assert (first.json(), second.json()) == ({"hits": 1}, {"hits": 2}) + + +@pytest.mark.integration +async def test_given_get_request_with_header_when_called_then_no_idempotency_row_is_persisted( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + # GIVEN: middleware configured for POST only; a GET endpoint hit with a key + app = FastAPI() + app.state.session_factory = session_factory + app.add_middleware(IdempotencyMiddleware, methods=frozenset({"POST"}), ttl_seconds=3600) + + @app.get("/snapshot") + async def snapshot() -> dict: + return {"ok": True} + + key = str(uuid4()) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # WHEN + await client.get("/snapshot", headers={"Idempotency-Key": key}) + + # THEN + row = await _row_for(session_factory, key) + assert row is None diff --git a/{{cookiecutter.project_slug}}/tests/unit/__init__.py b/{{cookiecutter.project_slug}}/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/unit/application/__init__.py b/{{cookiecutter.project_slug}}/tests/unit/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/unit/application/auth/__init__.py b/{{cookiecutter.project_slug}}/tests/unit/application/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/unit/application/auth/test_current_user.py b/{{cookiecutter.project_slug}}/tests/unit/application/auth/test_current_user.py new file mode 100644 index 0000000..685d122 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/unit/application/auth/test_current_user.py @@ -0,0 +1,55 @@ +"""Unit tests for the ``CurrentUser`` value object.""" + +from __future__ import annotations + +import pytest + +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser + + +@pytest.mark.unit +def test_given_user_with_admin_role_when_has_role_admin_then_returns_true() -> None: + # GIVEN + user = CurrentUser(subject="abc", roles=("admin", "viewer")) + + # WHEN + result = user.has_role("admin") + + # THEN + assert result is True + + +@pytest.mark.unit +def test_given_user_without_role_when_has_role_then_returns_false() -> None: + # GIVEN + user = CurrentUser(subject="abc", roles=("viewer",)) + + # WHEN + result = user.has_role("admin") + + # THEN + assert result is False + + +@pytest.mark.unit +def test_given_user_with_one_of_required_roles_when_has_any_role_then_returns_true() -> None: + # GIVEN + user = CurrentUser(subject="abc", roles=("viewer",)) + + # WHEN + result = user.has_any_role("admin", "viewer") + + # THEN + assert result is True + + +@pytest.mark.unit +def test_given_user_with_none_of_required_roles_when_has_any_role_then_returns_false() -> None: + # GIVEN + user = CurrentUser(subject="abc", roles=("guest",)) + + # WHEN + result = user.has_any_role("admin", "viewer") + + # THEN + assert result is False diff --git a/{{cookiecutter.project_slug}}/tests/unit/conftest.py b/{{cookiecutter.project_slug}}/tests/unit/conftest.py new file mode 100644 index 0000000..f02787a --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/unit/conftest.py @@ -0,0 +1,9 @@ +"""Unit-layer conftest. + +Reserved for fixtures that are only meaningful in unit tests (Fakes-only, +no I/O, no FastAPI app). Currently empty: ``frozen_clock``, +``sequential_ids``, ``seeded_random``, ``user_repository`` and +``ensure_user_exists_use_case`` live in the root conftest because both +unit tests and e2e tests use them. Add unit-only fixtures here as the +project grows. +""" diff --git a/{{cookiecutter.project_slug}}/tests/unit/domain/__init__.py b/{{cookiecutter.project_slug}}/tests/unit/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/unit/domain/test_email.py b/{{cookiecutter.project_slug}}/tests/unit/domain/test_email.py new file mode 100644 index 0000000..dc8987b --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/unit/domain/test_email.py @@ -0,0 +1,61 @@ +"""Unit tests for the ``Email`` value object.""" + +from __future__ import annotations + +import pytest + +from {{ cookiecutter.package_name }}.domain.value_objects.email import Email, InvalidEmailError + + +@pytest.mark.unit +def test_given_valid_address_when_constructing_email_then_value_is_lowercased() -> None: + # GIVEN + raw = "Alice@Example.COM" + + # WHEN + email = Email(raw) + + # THEN + assert email.value == "alice@example.com" + + +@pytest.mark.unit +def test_given_valid_address_when_constructing_email_then_str_is_lowercased() -> None: + # GIVEN + raw = "Alice@Example.COM" + + # WHEN + email = Email(raw) + + # THEN + assert str(email) == "alice@example.com" + + +@pytest.mark.unit +def test_given_two_emails_with_different_case_when_comparing_then_are_equal() -> None: + # GIVEN + a = Email("foo@bar.com") + b = Email("FOO@BAR.COM") + + # WHEN / THEN + assert a == b + + +@pytest.mark.unit +def test_given_string_without_at_sign_when_constructing_email_then_raises_invalid_email() -> None: + # GIVEN + raw = "not-an-email" + + # WHEN / THEN + with pytest.raises(InvalidEmailError): + Email(raw) + + +@pytest.mark.unit +def test_given_string_without_tld_when_constructing_email_then_raises_invalid_email() -> None: + # GIVEN + raw = "alice@localhost" + + # WHEN / THEN + with pytest.raises(InvalidEmailError): + Email(raw) diff --git a/{{cookiecutter.project_slug}}/tests/unit/domain/test_user.py b/{{cookiecutter.project_slug}}/tests/unit/domain/test_user.py new file mode 100644 index 0000000..4e070a5 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/unit/domain/test_user.py @@ -0,0 +1,58 @@ +"""Unit tests for the ``User`` entity.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from {{ cookiecutter.package_name }}.domain.entities.user import User +from {{ cookiecutter.package_name }}.domain.value_objects.email import Email +from {{ cookiecutter.package_name }}.domain.value_objects.user_id import UserId + + +def _user( + *, + uid: int = 1, + subject: str = "azure-sub-1", + email: str = "alice@example.com", + name: str = "Alice", +) -> User: + return User( + id=UserId(UUID(int=uid)), + subject=subject, + email=Email(email), + name=name, + created_at=datetime(2026, 5, 5, tzinfo=UTC), + ) + + +@pytest.mark.unit +def test_given_two_users_with_same_id_when_comparing_then_are_equal() -> None: + # GIVEN + a = _user(uid=42, email="a@b.com", name="Alpha") + b = _user(uid=42, email="x@y.com", name="Beta") + + # WHEN / THEN + assert a == b + + +@pytest.mark.unit +def test_given_two_users_with_same_id_when_hashing_then_hashes_are_equal() -> None: + # GIVEN + a = _user(uid=42, email="a@b.com", name="Alpha") + b = _user(uid=42, email="x@y.com", name="Beta") + + # WHEN / THEN + assert hash(a) == hash(b) + + +@pytest.mark.unit +def test_given_two_users_with_different_id_when_comparing_then_are_not_equal() -> None: + # GIVEN + a = _user(uid=1) + b = _user(uid=2) + + # WHEN / THEN + assert a != b diff --git a/{{cookiecutter.project_slug}}/tests/unit/infrastructure/jobs/__init__.py b/{{cookiecutter.project_slug}}/tests/unit/infrastructure/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/unit/infrastructure/jobs/test_backoff.py b/{{cookiecutter.project_slug}}/tests/unit/infrastructure/jobs/test_backoff.py new file mode 100644 index 0000000..b8ba5a3 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/unit/infrastructure/jobs/test_backoff.py @@ -0,0 +1,69 @@ +"""Unit tests for the outbox backoff schedule.""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from {{ cookiecutter.package_name }}.infrastructure.jobs.backoff import compute_next_attempt + + +@pytest.mark.unit +def test_given_first_failure_when_computing_next_attempt_then_returns_one_second() -> None: + # GIVEN + attempts = 1 + + # WHEN + delay = compute_next_attempt(attempts) + + # THEN + assert delay == timedelta(seconds=1) + + +@pytest.mark.unit +def test_given_third_failure_when_computing_next_attempt_then_returns_thirty_seconds() -> None: + # GIVEN + attempts = 3 + + # WHEN + delay = compute_next_attempt(attempts) + + # THEN + assert delay == timedelta(seconds=30) + + +@pytest.mark.unit +def test_given_eighth_failure_when_computing_next_attempt_then_caps_at_one_hour() -> None: + # GIVEN + attempts = 8 + + # WHEN + delay = compute_next_attempt(attempts) + + # THEN + assert delay == timedelta(hours=1) + + +@pytest.mark.unit +def test_given_far_beyond_schedule_when_computing_next_attempt_then_still_capped_at_one_hour() -> None: + # GIVEN + attempts = 999 + + # WHEN + delay = compute_next_attempt(attempts) + + # THEN + assert delay == timedelta(hours=1) + + +@pytest.mark.unit +def test_given_zero_attempts_when_computing_next_attempt_then_treats_as_first_failure() -> None: + # GIVEN + attempts = 0 + + # WHEN + delay = compute_next_attempt(attempts) + + # THEN + assert delay == timedelta(seconds=1) diff --git a/{{cookiecutter.project_slug}}/tests/unit/presentation/__init__.py b/{{cookiecutter.project_slug}}/tests/unit/presentation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/unit/presentation/test_auth_dependencies.py b/{{cookiecutter.project_slug}}/tests/unit/presentation/test_auth_dependencies.py new file mode 100644 index 0000000..18f6f18 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/unit/presentation/test_auth_dependencies.py @@ -0,0 +1,125 @@ +"""Unit tests for the auth dependencies — bearer parsing, verification, roles.""" + +from __future__ import annotations + +import pytest +from fastapi import Request +from fastapi.security import HTTPAuthorizationCredentials + +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser +from {{ cookiecutter.package_name }}.domain.exceptions.auth import ( + InsufficientPermissionsError, + InvalidTokenError, + MissingTokenError, +) +from {{ cookiecutter.package_name }}.presentation.api.dependencies.auth import ( + get_bearer_token, + get_current_user, + get_current_user_optional, + require_roles, +) + +from tests.fakes.auth import FakeTokenVerifier + + +def _request_with(headers: dict[str, str]) -> Request: + """Build a starlette ``Request`` with the given headers.""" + scope = { + "type": "http", + "headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()], + } + return Request(scope) + + +@pytest.mark.unit +def test_given_valid_bearer_credentials_when_extracting_token_then_returns_token() -> None: + # GIVEN + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="abc.def.ghi") + + # WHEN + token = get_bearer_token(credentials) + + # THEN + assert token == "abc.def.ghi" + + +@pytest.mark.unit +def test_given_no_credentials_when_extracting_token_then_raises_missing_token() -> None: + # GIVEN: no Authorization header was provided + + # WHEN / THEN + with pytest.raises(MissingTokenError): + get_bearer_token(None) + + +@pytest.mark.unit +def test_given_non_bearer_scheme_when_extracting_token_then_raises_missing_token() -> None: + # GIVEN + credentials = HTTPAuthorizationCredentials(scheme="Basic", credentials="dXNlcjpwYXNz") + + # WHEN / THEN + with pytest.raises(MissingTokenError): + get_bearer_token(credentials) + + +@pytest.mark.unit +def test_given_known_token_when_resolving_current_user_then_returns_user( + token_verifier: FakeTokenVerifier, +) -> None: + # GIVEN + expected = token_verifier.accept("good-token", subject="alice", roles=("admin",)) + + # WHEN + resolved = get_current_user(token="good-token", verifier=token_verifier) + + # THEN + assert resolved == expected + + +@pytest.mark.unit +def test_given_unknown_token_when_resolving_current_user_then_raises_invalid_token( + token_verifier: FakeTokenVerifier, +) -> None: + # GIVEN: no tokens registered + + # WHEN / THEN + with pytest.raises(InvalidTokenError): + get_current_user(token="bad-token", verifier=token_verifier) + + +@pytest.mark.unit +def test_given_no_header_when_resolving_optional_then_returns_none( + token_verifier: FakeTokenVerifier, +) -> None: + # GIVEN + request = _request_with({}) + + # WHEN + user = get_current_user_optional(request=request, verifier=token_verifier) + + # THEN + assert user is None + + +@pytest.mark.unit +def test_given_user_with_required_role_when_require_roles_then_returns_user() -> None: + # GIVEN + user = CurrentUser(subject="alice", roles=("admin",)) + dep = require_roles("admin") + + # WHEN + resolved = dep(user=user) + + # THEN + assert resolved is user + + +@pytest.mark.unit +def test_given_user_without_required_role_when_require_roles_then_raises_insufficient_permissions() -> None: + # GIVEN + user = CurrentUser(subject="bob", roles=("viewer",)) + dep = require_roles("admin") + + # WHEN / THEN + with pytest.raises(InsufficientPermissionsError): + dep(user=user) diff --git a/{{cookiecutter.project_slug}}/tests/unit/presentation/test_error_mapping.py b/{{cookiecutter.project_slug}}/tests/unit/presentation/test_error_mapping.py new file mode 100644 index 0000000..194fe1d --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/unit/presentation/test_error_mapping.py @@ -0,0 +1,42 @@ +"""The HTTP mapping is exhaustive over ``DomainError`` subclasses. + +This test catches the case where a developer adds a new ``DomainError`` and +forgets to declare its HTTP status code in ``ERROR_HTTP_MAPPING``. +""" + +from __future__ import annotations + +import pytest + +# Import representative modules so all subclasses are discovered. +from {{ cookiecutter.package_name }}.domain.exceptions import user as _user # noqa: F401 +from {{ cookiecutter.package_name }}.domain.exceptions.base import DomainError +from {{ cookiecutter.package_name }}.domain.value_objects import email as _email # noqa: F401 +from {{ cookiecutter.package_name }}.presentation.api.error_handlers import ERROR_HTTP_MAPPING + + +def _all_subclasses(cls: type) -> set[type]: + seen: set[type] = set() + stack: list[type] = list(cls.__subclasses__()) + while stack: + node = stack.pop() + if node in seen: + continue + seen.add(node) + stack.extend(node.__subclasses__()) + return seen + + +@pytest.mark.unit +def test_given_all_domain_errors_when_inspecting_mapping_then_each_has_http_status() -> None: + # GIVEN + subclasses = _all_subclasses(DomainError) + + # WHEN + missing = sorted(cls.__name__ for cls in subclasses if cls not in ERROR_HTTP_MAPPING) + + # THEN + assert missing == [], ( + f"DomainError subclasses missing from ERROR_HTTP_MAPPING: {missing}. " + "Add an entry in presentation/api/error_handlers.py." + ) diff --git a/{{cookiecutter.project_slug}}/tests/unit/use_cases/__init__.py b/{{cookiecutter.project_slug}}/tests/unit/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/tests/unit/use_cases/test_ensure_user_exists.py b/{{cookiecutter.project_slug}}/tests/unit/use_cases/test_ensure_user_exists.py new file mode 100644 index 0000000..c9a8a13 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/unit/use_cases/test_ensure_user_exists.py @@ -0,0 +1,143 @@ +"""Outside-in TDD for ``EnsureUserExistsUseCase``. + +This use case backs the SSO auto-provisioning flow: on every +authenticated request, the route's ``Depends(get_or_provision_user)`` +chains the verified ``CurrentUser`` into this use case. The first time +a JWT subject is seen, a row is created; subsequent calls return the +existing row. +""" + +from __future__ import annotations + +import pytest + +from {{ cookiecutter.package_name }}.application.auth.current_user import CurrentUser +from {{ cookiecutter.package_name }}.application.dtos.user import EnsureUserExistsInput +from {{ cookiecutter.package_name }}.application.use_cases.ensure_user_exists import ( + EnsureUserExistsUseCase, +) +from {{ cookiecutter.package_name }}.domain.exceptions.auth import MissingProfileClaimsError + +from tests.fakes.event_bus import InMemoryEventBus +from tests.fakes.repositories.user import InMemoryUserRepository + + +def _claims( + *, + subject: str = "azure-sub-abc", + email: str | None = "alice@example.com", + name: str | None = "Alice", +) -> CurrentUser: + return CurrentUser(subject=subject, email=email, name=name) + + +@pytest.mark.unit +async def test_given_subject_never_seen_when_ensuring_then_creates_one_user( + ensure_user_exists_use_case: EnsureUserExistsUseCase, + user_repository: InMemoryUserRepository, +) -> None: + # GIVEN + claims = _claims(subject="azure-sub-1", email="alice@example.com", name="Alice") + + # WHEN + await ensure_user_exists_use_case.execute(EnsureUserExistsInput(current_user=claims)) + + # THEN + assert len(user_repository.users) == 1 + + +@pytest.mark.unit +async def test_given_subject_already_persisted_when_ensuring_then_no_new_user_is_created( + ensure_user_exists_use_case: EnsureUserExistsUseCase, + user_repository: InMemoryUserRepository, +) -> None: + # GIVEN + claims = _claims(subject="azure-sub-2", email="bob@example.com", name="Bob") + await ensure_user_exists_use_case.execute(EnsureUserExistsInput(current_user=claims)) + + # WHEN + await ensure_user_exists_use_case.execute(EnsureUserExistsInput(current_user=claims)) + + # THEN + assert len(user_repository.users) == 1 + + +@pytest.mark.unit +async def test_given_subject_already_persisted_when_ensuring_then_returns_existing_user( + ensure_user_exists_use_case: EnsureUserExistsUseCase, + user_repository: InMemoryUserRepository, +) -> None: + # GIVEN + claims = _claims(subject="azure-sub-3", email="carol@example.com", name="Carol") + first = await ensure_user_exists_use_case.execute( + EnsureUserExistsInput(current_user=claims) + ) + + # WHEN + second = await ensure_user_exists_use_case.execute( + EnsureUserExistsInput(current_user=claims) + ) + + # THEN + assert second.id == first.id + + +@pytest.mark.unit +async def test_given_claims_without_email_when_ensuring_then_raises_missing_profile_claims( + ensure_user_exists_use_case: EnsureUserExistsUseCase, +) -> None: + # GIVEN + claims = _claims(subject="azure-sub-4", email=None, name="Dan") + + # WHEN / THEN + with pytest.raises(MissingProfileClaimsError): + await ensure_user_exists_use_case.execute( + EnsureUserExistsInput(current_user=claims) + ) + + +@pytest.mark.unit +async def test_given_claims_without_name_when_ensuring_then_raises_missing_profile_claims( + ensure_user_exists_use_case: EnsureUserExistsUseCase, +) -> None: + # GIVEN + claims = _claims(subject="azure-sub-5", email="erin@example.com", name=None) + + # WHEN / THEN + with pytest.raises(MissingProfileClaimsError): + await ensure_user_exists_use_case.execute( + EnsureUserExistsInput(current_user=claims) + ) + + +@pytest.mark.unit +async def test_given_first_provision_when_inspecting_event_then_publishes_user_provisioned( + ensure_user_exists_use_case: EnsureUserExistsUseCase, + event_bus: InMemoryEventBus, +) -> None: + # GIVEN + claims = _claims(subject="azure-sub-6", email="fred@example.com", name="Fred") + + # WHEN + await ensure_user_exists_use_case.execute(EnsureUserExistsInput(current_user=claims)) + + # THEN + event_name, _ = event_bus.published[0] + assert event_name == "user.provisioned" + + +@pytest.mark.unit +async def test_given_already_persisted_when_ensuring_again_then_does_not_republish_event( + ensure_user_exists_use_case: EnsureUserExistsUseCase, + event_bus: InMemoryEventBus, +) -> None: + # GIVEN + claims = _claims(subject="azure-sub-7", email="gail@example.com", name="Gail") + await ensure_user_exists_use_case.execute(EnsureUserExistsInput(current_user=claims)) + initial_events = len(event_bus.published) + + # WHEN + await ensure_user_exists_use_case.execute(EnsureUserExistsInput(current_user=claims)) + + # THEN + assert len(event_bus.published) == initial_events