From 7d4d2377e2946fd51781b2a1c5146d05d66115bf Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:31:21 +0530 Subject: [PATCH 01/11] chore: drop DSS config with nothing to act on here - `scripts` in ruff's paths and isort, and pytest's `pythonpath`, served DSS folders. - This repo has neither, so the settings and their comments explained nothing. - The e2e marker named an env var from before the prefix was settled. --- pyproject.toml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bbee612..57916f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dev = [ # --------------------------------------------------------------------------- [tool.ruff] line-length = 88 -src = ["src", "tests", "scripts"] +src = ["src", "tests"] target-version = "py313" # Docs contain illustrative Python in fenced blocks whose alignment is # deliberate. Formatting them rewrites the prose's meaning, so they are excluded. @@ -55,11 +55,6 @@ select = [ "B", # flake8-bugbear ] -[tool.ruff.lint.isort] -# `scripts/` is ours but is not packaged (only `src/experience_api` ships), so -# isort has no way to infer it and files it under third-party beside pytest. -known-first-party = ["experience_api", "scripts", "tests"] - # --------------------------------------------------------------------------- # Editors default to the system interpreter and then cannot resolve pytest or # pydantic. This points them at the project venv. Type checking is not a gate; @@ -80,7 +75,6 @@ include = ["src", "tests"] [tool.pytest.ini_options] minversion = "9.0" testpaths = ["tests"] -pythonpath = ["."] # --import-mode=importlib: test folders mirror src/, so sibling packages may # each hold a `test_mapping.py`. Under the default prepend mode those collide on # the module name; importlib gives each its own full path. @@ -99,6 +93,6 @@ addopts = [ # test regardless of marker. asyncio_mode = "auto" markers = [ - "e2e: one turn against a real DSS; needs XAPI_DSS_BASE_URL to point at one", + "e2e: one turn against a real DSS; needs a running DSS", ] strict_markers = true From 7e94a701ff76467543c32d1ab8d185592582429c Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:31:39 +0530 Subject: [PATCH 02/11] feat: create_app() with a lifespan - The composition root, with nothing wired yet. - `asgi-lifespan` (dev) runs the lifespan, which httpx's ASGI transport does not. --- pyproject.toml | 1 + src/experience_api/app.py | 21 +++++++++++++++++++++ tests/api/test_app.py | 11 +++++++++++ tests/conftest.py | 35 +++++++++++++++++++++++++++++++++++ uv.lock | 23 +++++++++++++++++++++++ 5 files changed, 91 insertions(+) create mode 100644 src/experience_api/app.py create mode 100644 tests/api/test_app.py diff --git a/pyproject.toml b/pyproject.toml index 57916f2..c8aa540 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ packages = ["src/experience_api"] [dependency-groups] dev = [ + "asgi-lifespan>=2.1.0", "pytest>=9.1.1", "pytest-asyncio>=1.0.0", # Coverage is reported on every run (report-only, no gate) so regressions diff --git a/src/experience_api/app.py b/src/experience_api/app.py new file mode 100644 index 0000000..23dd7fb --- /dev/null +++ b/src/experience_api/app.py @@ -0,0 +1,21 @@ +"""The composition root: `create_app()` builds the FastAPI app. + +`uvicorn --factory` calls it with no arguments, so it must boot with nothing +configured. Objects the app needs for its whole life, such as an HTTP client +pool, are built in `lifespan`; none exist yet. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + + +def create_app() -> FastAPI: + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + yield + + return FastAPI(title="Experience API", lifespan=lifespan) diff --git a/tests/api/test_app.py b/tests/api/test_app.py new file mode 100644 index 0000000..bcb597b --- /dev/null +++ b/tests/api/test_app.py @@ -0,0 +1,11 @@ +"""`create_app()` must boot with no arguments and no environment, because that +is how `uvicorn --factory` calls it.""" + +import httpx + + +async def test_app_boots_and_describes_itself(running: httpx.AsyncClient) -> None: + response = await running.get("/openapi.json") + + assert response.status_code == 200 + assert response.json()["info"]["title"] == "Experience API" diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..5cb89a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +"""Fixtures shared by every tier.""" + +from collections.abc import AsyncIterator + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from fastapi import FastAPI + +from experience_api.app import create_app + + +@pytest.fixture +def app() -> FastAPI: + """The app as `uvicorn --factory` builds it. A module that needs different + wiring overrides this fixture with its own `create_app(...)` call.""" + + return create_app() + + +@pytest.fixture +async def running(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]: + """The app with its lifespan started, behind an in-process HTTP client. + + httpx's ASGI transport does not run lifespan events, and everything the app + needs is built there, so a test that skips it would hit an empty `app.state`. + """ + + async with ( + LifespanManager(app), + httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client, + ): + yield client diff --git a/uv.lock b/uv.lock index 7a93d34..72ee206 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "asgi-lifespan" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/da/e7908b54e0f8043725a990bf625f2041ecf6bfe8eb7b19407f1c00b630f7/asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308", size = 15627, upload-time = "2023-03-28T17:35:49.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -157,6 +169,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "asgi-lifespan" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -173,6 +186,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "asgi-lifespan", specifier = ">=2.1.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -438,6 +452,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "starlette" version = "1.7.0" From 9eefd0dc09503aae511cc9995f4399847845401e Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:31:52 +0530 Subject: [PATCH 03/11] feat: GET /healthz - For Docker and the front proxy. Not in the contract, so not in the OpenAPI document. - Says the process is up. It does not probe the DSS, so an outage keeps it in rotation. --- src/experience_api/app.py | 20 ++++++++++++++++++-- tests/api/test_health.py | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/api/test_health.py diff --git a/src/experience_api/app.py b/src/experience_api/app.py index 23dd7fb..10018f7 100644 --- a/src/experience_api/app.py +++ b/src/experience_api/app.py @@ -3,6 +3,9 @@ `uvicorn --factory` calls it with no arguments, so it must boot with nothing configured. Objects the app needs for its whole life, such as an HTTP client pool, are built in `lifespan`; none exist yet. + +`/healthz` lives here because it belongs to the running service, not to any +feature. """ from __future__ import annotations @@ -10,7 +13,18 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI + +health = APIRouter() + + +@health.get("/healthz", include_in_schema=False) +async def healthz() -> dict[str, str]: + """For Docker and the front proxy. Says the process is up and serving. It + does not probe the DSS, so a DSS outage never takes the API out of rotation + with it.""" + + return {"status": "ok"} def create_app() -> FastAPI: @@ -18,4 +32,6 @@ def create_app() -> FastAPI: async def lifespan(app: FastAPI) -> AsyncIterator[None]: yield - return FastAPI(title="Experience API", lifespan=lifespan) + app = FastAPI(title="Experience API", lifespan=lifespan) + app.include_router(health) + return app diff --git a/tests/api/test_health.py b/tests/api/test_health.py new file mode 100644 index 0000000..f7e8eb5 --- /dev/null +++ b/tests/api/test_health.py @@ -0,0 +1,19 @@ +"""`GET /healthz` is for whoever runs the service, such as Docker or the front +proxy. It is not for the client, so it is not in the contract.""" + +import httpx + + +async def test_healthz_says_ok(running: httpx.AsyncClient) -> None: + response = await running.get("/healthz") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +async def test_healthz_is_not_in_the_openapi_document( + running: httpx.AsyncClient, +) -> None: + paths = (await running.get("/openapi.json")).json()["paths"] + + assert "/healthz" not in paths From 7330ed9bb02ad854b8434d39021dab28f609b66a Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:33:09 +0530 Subject: [PATCH 04/11] test: enforce the dependency rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Architecture §4.1 as an AST walk: each module's layer, by path, limits its imports. - Relative and dynamic imports are resolved; a module that fits no layer fails too. - A service takes config as plain values; only app.py may read Settings. - A second feature is reachable only through its package, never its insides. - ruff bans relative imports, so the easy bypass is never written. --- pyproject.toml | 7 + tests/test_boundaries.py | 361 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 tests/test_boundaries.py diff --git a/pyproject.toml b/pyproject.toml index c8aa540..d35ff60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,13 @@ select = [ "B", # flake8-bugbear ] +# Absolute imports only. A relative import hides which layer it reaches into, +# and `from .adapters import x` inside a feature is the easiest way to break the +# dependency rule. tests/test_boundaries.py resolves them anyway; this stops +# them being written. +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + # --------------------------------------------------------------------------- # Editors default to the system interpreter and then cannot resolve pytest or # pydantic. This points them at the project venv. Type checking is not a gate; diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py new file mode 100644 index 0000000..7772ab0 --- /dev/null +++ b/tests/test_boundaries.py @@ -0,0 +1,361 @@ +"""The dependency rule, enforced. + +The rule is §4.1 of the architecture (`docs/ADR/0001-architecture.md`). Features +sit at the top and keep their layers inside, so the folders no longer show the +layers. This test does. Every module under `src/experience_api` is placed in a +layer by its path, and each import is checked against what that layer may use. +A module that fits no layer fails too, so a stray `chat/util.py` or a new root +module is caught. + +What each layer may import, beyond the standard library: + +- `app.py` and the package `__init__`: anything. They are the composition root. +- `settings.py`: pydantic, pydantic_settings. +- `shared/**`: pydantic, fastapi, starlette, and other `shared` modules. +- `/__init__.py`: anything in its own feature. What it re-exports is + the feature's public face. +- `/domain.py`: pydantic. +- `/service.py`, `ports.py`: their own `domain` and `ports`, nothing + else. Config reaches a service as plain constructor arguments, never as + `Settings`, so only `app.py` reads settings. +- `/adapters//**`: any third party; the feature's own `domain` + and `ports`; siblings in the same `adapters/`; `shared`; and another + feature's package itself (`experience_api.auth`), never its insides. + +Imports are read from the AST, never executed. A relative import is resolved to +its absolute name first. `from pkg import mod` counts as importing `pkg.mod` +when `mod` is a module. String literals passed to `import_module` or +`__import__` are checked too. +""" + +from __future__ import annotations + +import ast +import sys +from dataclasses import dataclass +from pathlib import Path + +import pytest + +PACKAGE = "experience_api" +SRC = Path(__file__).resolve().parents[1] / "src" / PACKAGE + +STDLIB = frozenset(sys.stdlib_module_names) +ROOT_MODULES = frozenset({"app", "settings", "shared"}) + + +@dataclass(frozen=True) +class Layer: + name: str + # Top-level packages outside ours it may import. None means any. + third_party: frozenset[str] | None + # Our modules it may import, relative to the package. "x.*" is x and below. + internal: tuple[str, ...] + # Whether another feature's package itself may be imported. + other_features: bool = False + + +def classify(module: tuple[str, ...], is_package: bool) -> Layer | None: + """The layer a module is in, or None when the layout has no place for it.""" + + if module in ((), ("app",)): + return Layer("app", None, ("*",)) + if module == ("settings",): + return Layer("settings", frozenset({"pydantic", "pydantic_settings"}), ()) + if module[0] == "shared": + return Layer( + "shared", frozenset({"pydantic", "fastapi", "starlette"}), ("shared.*",) + ) + + feature = module[0] + if len(module) == 1: + return Layer("feature", None, (f"{feature}.*",)) if is_package else None + if module[1:] == ("domain",): + return Layer("domain", frozenset({"pydantic"}), ()) + if module[1:] in (("service",), ("ports",)): + return Layer("service", frozenset(), (f"{feature}.domain", f"{feature}.ports")) + if module[1] == "adapters": + own = [f"{feature}.domain", f"{feature}.ports", "shared.*"] + if len(module) >= 3: + own.append(f"{feature}.adapters.{module[2]}.*") + return Layer("adapter", None, tuple(own), other_features=True) + return None + + +def _is_module(name: str, src: Path, package: str) -> bool: + parts = name.split(".") + if parts[0] != package or len(parts) == 1: + return False + path = src.joinpath(*parts[1:]) + return path.with_suffix(".py").is_file() or (path / "__init__.py").is_file() + + +def imported_names( + tree: ast.AST, *, here: tuple[str, ...], src: Path, package: str +) -> set[str]: + """Every absolute module name a module imports. + + `here` is the dotted package the module sits in, which a relative import is + resolved against. + """ + + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + base = here[: len(here) - node.level + 1] if node.level else () + prefix = ".".join((*base, node.module) if node.module else base) + for alias in node.names: + candidate = f"{prefix}.{alias.name}" + names.add(candidate if _is_module(candidate, src, package) else prefix) + elif isinstance(node, ast.Call): + target = node.func + dynamic = (isinstance(target, ast.Name) and target.id == "__import__") or ( + isinstance(target, ast.Attribute) and target.attr == "import_module" + ) + if dynamic: + names.update( + arg.value + for arg in node.args + if isinstance(arg, ast.Constant) and isinstance(arg.value, str) + ) + return names + + +def _matches(target: str, pattern: str) -> bool: + if pattern == "*": + return True + if pattern.endswith(".*"): + base = pattern[:-2] + return target == base or target.startswith(base + ".") + return target == pattern + + +def _allowed(name: str, layer: Layer, feature: str, package: str) -> bool: + root = name.split(".")[0] + if root in STDLIB: + return True + if root != package: + return layer.third_party is None or root in layer.third_party + target = name[len(package) + 1 :] + if any(_matches(target, pattern) for pattern in layer.internal): + return True + return ( + layer.other_features + and bool(target) + and "." not in target + and target != feature + and target not in ROOT_MODULES + ) + + +def violations(src: Path, package: str = PACKAGE) -> list[str]: + """Every forbidden import under `src`, as `path -> name: reason`.""" + + found: list[str] = [] + for path in sorted(src.rglob("*.py")): + rel = path.relative_to(src) + is_package = path.name == "__init__.py" + parts = rel.with_suffix("").parts + module = parts[:-1] if is_package else parts + layer = classify(module, is_package) + if layer is None: + found.append(f"{rel.as_posix()}: fits no layer") + continue + here = (package, *(module if is_package else module[:-1])) + tree = ast.parse(path.read_text(), filename=str(path)) + feature = module[0] if module else "" + for name in sorted(imported_names(tree, here=here, src=src, package=package)): + if not _allowed(name, layer, feature, package): + found.append( + f"{rel.as_posix()} -> {name}: {layer.name} may not import it" + ) + return found + + +# --------------------------------------------------------------------------- +# The live tree. +# --------------------------------------------------------------------------- + + +def test_the_package_has_modules_to_check() -> None: + assert list(SRC.rglob("*.py")), f"no modules under {SRC}; the rule is vacuous" + + +def test_every_module_obeys_the_dependency_rule() -> None: + assert violations(SRC) == [] + + +# --------------------------------------------------------------------------- +# The checker itself, on temporary trees. One tree that uses every allowance and +# must pass, then one case per way to break the rule. +# --------------------------------------------------------------------------- + + +def _tree(root: Path, files: dict[str, str]) -> Path: + src = root / PACKAGE + for rel, body in files.items(): + path = src / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body) + return src + + +ALLOWED = { + "__init__.py": "", + "app.py": ( + "import httpx\n" + f"from {PACKAGE}.chat import ChatService\n" + f"from {PACKAGE}.chat.adapters.http import routes\n" + ), + "settings.py": "from pydantic_settings import BaseSettings\n", + "shared/__init__.py": "", + "shared/errors.py": ( + "from fastapi import FastAPI\n" + "from starlette.exceptions import HTTPException\n" + f"from {PACKAGE}.shared import ids\n" + ), + "shared/ids.py": "import uuid\n", + "chat/__init__.py": f"from {PACKAGE}.chat.service import ChatService\n", + "chat/domain.py": ( + "from dataclasses import dataclass\nfrom pydantic import BaseModel\n" + ), + "chat/ports.py": ( + f"from typing import Protocol\nfrom {PACKAGE}.chat.domain import X\n" + ), + "chat/service.py": "from .domain import X\nfrom .ports import DssClient\n", + "chat/adapters/__init__.py": "", + "chat/adapters/http/__init__.py": "", + "chat/adapters/http/routes.py": ( + "from fastapi import APIRouter\n" + f"from {PACKAGE}.auth import current_user\n" + f"from {PACKAGE}.chat import domain\n" + f"from {PACKAGE}.shared.errors import AppError\n" + "from . import schemas\n" + ), + "chat/adapters/http/schemas.py": "", + "auth/__init__.py": "", +} + + +def test_every_allowance_passes(tmp_path: Path) -> None: + assert violations(_tree(tmp_path, ALLOWED)) == [] + + +CHAT = {"chat/__init__.py": "", "chat/adapters/__init__.py": ""} +CHAT_HTTP = {**CHAT, "chat/adapters/http/__init__.py": ""} +CHAT_DSS = { + **CHAT, + "chat/adapters/dss/__init__.py": "", + "chat/adapters/dss/fake.py": "", +} +AUTH = { + "auth/__init__.py": "", + "auth/service.py": "", + "auth/adapters/__init__.py": "", + "auth/adapters/http/__init__.py": "", + "auth/adapters/http/routes.py": "", +} +SHARED = {"shared/__init__.py": "", "shared/errors.py": ""} + +BREAKS = { + "domain imports the framework": ( + {**CHAT, "chat/domain.py": "import fastapi\n"}, + "chat/domain.py -> fastapi: domain may not import it", + ), + "domain reaches shared by a relative import": ( + {**CHAT, **SHARED, "chat/domain.py": "from ..shared import errors\n"}, + f"chat/domain.py -> {PACKAGE}.shared.errors: domain may not import it", + ), + "service imports an adapter by a relative import": ( + {**CHAT_DSS, "chat/service.py": "from .adapters.dss import fake\n"}, + f"chat/service.py -> {PACKAGE}.chat.adapters.dss.fake: " + "service may not import it", + ), + "service imports an adapter by name": ( + {**CHAT_DSS, "chat/service.py": f"import {PACKAGE}.chat.adapters.dss\n"}, + f"chat/service.py -> {PACKAGE}.chat.adapters.dss: service may not import it", + ), + "service imports an adapter dynamically": ( + { + **CHAT_DSS, + "chat/service.py": ( + "import importlib\n" + f"importlib.import_module('{PACKAGE}.chat.adapters')\n" + ), + }, + f"chat/service.py -> {PACKAGE}.chat.adapters: service may not import it", + ), + "service imports a third-party package": ( + {**CHAT, "chat/service.py": "import httpx\n"}, + "chat/service.py -> httpx: service may not import it", + ), + "service reads Settings instead of taking plain values": ( + { + **CHAT, + "settings.py": "", + "chat/service.py": f"from {PACKAGE} import settings\n", + }, + f"chat/service.py -> {PACKAGE}.settings: service may not import it", + ), + "adapter reaches into another feature's adapters": ( + { + **CHAT_HTTP, + **AUTH, + "chat/adapters/http/routes.py": ( + f"from {PACKAGE}.auth.adapters.http import routes\n" + ), + }, + f"chat/adapters/http/routes.py -> {PACKAGE}.auth.adapters.http.routes: " + "adapter may not import it", + ), + "adapter reaches into another feature's service": ( + { + **CHAT_HTTP, + **AUTH, + "chat/adapters/http/routes.py": f"from {PACKAGE}.auth import service\n", + }, + f"chat/adapters/http/routes.py -> {PACKAGE}.auth.service: " + "adapter may not import it", + ), + "one adapter bypasses the port to reach another": ( + { + **CHAT_HTTP, + **CHAT_DSS, + "chat/adapters/http/routes.py": ( + f"from {PACKAGE}.chat.adapters.dss import fake\n" + ), + }, + f"chat/adapters/http/routes.py -> {PACKAGE}.chat.adapters.dss.fake: " + "adapter may not import it", + ), + "shared imports a feature": ( + { + **SHARED, + **CHAT, + "chat/domain.py": "", + "shared/ids.py": (f"from {PACKAGE}.chat import domain\n"), + }, + f"shared/ids.py -> {PACKAGE}.chat.domain: shared may not import it", + ), + "settings imports the framework": ( + {"settings.py": "import fastapi\n"}, + "settings.py -> fastapi: settings may not import it", + ), + "a feature module that fits no layer": ( + {**CHAT, "chat/util.py": ""}, + "chat/util.py: fits no layer", + ), + "a root module that fits no layer": ( + {"helpers.py": ""}, + "helpers.py: fits no layer", + ), +} + + +@pytest.mark.parametrize(("files", "expected"), BREAKS.values(), ids=BREAKS.keys()) +def test_each_break_is_caught( + tmp_path: Path, files: dict[str, str], expected: str +) -> None: + assert violations(_tree(tmp_path, files)) == [expected] From 9098b8cc3834429aba6315ff65f59161e4828996 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:33:19 +0530 Subject: [PATCH 05/11] chore: ruff and pytest in CI - Same workflow as the DSS: uv sync --locked, ruff check, ruff format --check, pytest. --- .github/workflows/run-tests.yml | 35 +++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/run-tests.yml diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..369c819 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,35 @@ +name: Run Tests + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --locked + + - name: Lint + run: uv run ruff check . + + - name: Check formatting + run: uv run ruff format --check . + + # The e2e tier needs a running DSS; the `e2e` marker keeps it out of the + # default run (pyproject.toml). + - name: Test + run: uv run pytest From ddb1424d2d8426af8ff94250d17636115de54b00 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:33:40 +0530 Subject: [PATCH 06/11] chore: pre-commit hooks, installed with uv - Ruff and the usual hygiene hooks on commit; the test suite on push. - `pre-commit` is a dev dependency: hooks install with `uv run`, no global tool. --- .pre-commit-config.yaml | 35 +++++++++++ README.md | 8 +++ pyproject.toml | 1 + uv.lock | 135 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2df5239 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +# Install once per clone: +# uv run pre-commit install --hook-type pre-commit --hook-type pre-push +# Local hooks can be bypassed with `--no-verify`, so CI runs the same checks. + +# Every hook runs on commit unless it names its own stage. +default_stages: [pre-commit] + +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.4 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files + + - repo: local + hooks: + # The full test suite before code leaves the machine (CONVENTIONS.md). + - id: pytest + name: pytest + entry: uv run pytest -q + language: system + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/README.md b/README.md index e6e099c..74b66dd 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,11 @@ request, calls the DSS, and streams the answer back. uv sync uv run ruff check . && uv run ruff format --check . && uv run pytest ``` + +Once per clone, install the git hooks. Lint runs on every commit; the test +suite runs before every push. CI runs the same checks, so `--no-verify` only +moves a failure later. + +```bash +uv run pre-commit install --hook-type pre-commit --hook-type pre-push +``` diff --git a/pyproject.toml b/pyproject.toml index d35ff60..e97714a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ packages = ["src/experience_api"] [dependency-groups] dev = [ "asgi-lifespan>=2.1.0", + "pre-commit>=4.0.0", "pytest>=9.1.1", "pytest-asyncio>=1.0.0", # Coverage is reported on every run (report-only, no gate) so regressions diff --git a/uv.lock b/uv.lock index 72ee206..60da877 100644 --- a/uv.lock +++ b/uv.lock @@ -54,6 +54,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -156,6 +165,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + [[package]] name = "experience-api" version = "0.1.0" @@ -170,6 +188,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "asgi-lifespan" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -187,6 +206,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "asgi-lifespan", specifier = ">=2.1.0" }, + { name = "pre-commit", specifier = ">=4.0.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -209,6 +229,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] +[[package]] +name = "filelock" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/38/88cd6eda96c40594a1e3da7d8b40f04bc40ace5a6aef9ac5cb407540f173/filelock-4.0.1.tar.gz", hash = "sha256:fdefc3f3e87716d855ae2b732c1cfd521dd99799ef2b4d00e8c0d4dcdc7cc94b", size = 238888, upload-time = "2026-09-19T01:08:15.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/33/af0635ab07fe83b1788a1dbe370ff3e226062495a998335cb18a1cac81aa/filelock-4.0.1-py3-none-any.whl", hash = "sha256:481a321a27bef441e23c53371c6abc8d7d16e26b97090074ba44f7538a3fd55a", size = 106219, upload-time = "2026-09-19T01:08:14.49Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -246,6 +275,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.20" @@ -264,6 +302,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -273,6 +320,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "platformdirs" +version = "4.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/dd/65804b0c2925a1c821a05502ea57517b69a073ff400d25ab9faa3a2cf012/platformdirs-4.11.12.tar.gz", hash = "sha256:e8dc1cb58f1153fd7f61db1374317770baababec2480b37b8f01c6cc25b45267", size = 40016, upload-time = "2026-09-22T01:02:36.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/2b/69de6317fbf48865bb12aa56c9cfcfff46b3add0cd45250725d96146dce0/platformdirs-4.11.12-py3-none-any.whl", hash = "sha256:b1ba966ac76153d9985a0787ffab951e7c18416189873d4e4ddb2c8a04a0a254", size = 24976, upload-time = "2026-09-22T01:02:35.742Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -282,6 +338,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -418,6 +490,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-discovery" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.3" @@ -427,6 +511,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.16.8" @@ -506,3 +626,18 @@ sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26 wheels = [ { url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" }, ] + +[[package]] +name = "virtualenv" +version = "21.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/87/d29c3edf5dae96555aa69499e0bfaf48dd41b889a05a04d41884bcdb5c5c/virtualenv-21.11.1.tar.gz", hash = "sha256:6266bdc327ed2a760f628ecbdadc776745fe08c3cac6139417bc2437e4d061dd", size = 5395254, upload-time = "2026-09-23T15:01:07.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/7b/7ca6956dd5fd8298682186782de4a33c507552bce206adf6b0f2a3596353/virtualenv-21.11.1-py3-none-any.whl", hash = "sha256:00596e6238363a1a69fd899a6e67365830be0b0bff9a9c83bdcad6e9aee3c419", size = 5485002, upload-time = "2026-09-23T15:01:04.935Z" }, +] From 602cf04c174ef871f41b276267545ed496cfa2f9 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:34:09 +0530 Subject: [PATCH 07/11] chore: pyright as a gate - `standard` mode, on every commit and in CI. Ruff does not check types; this does. - The hook runs whatever changed, since pyproject.toml alone can loosen the check. --- .github/workflows/run-tests.yml | 3 +++ .pre-commit-config.yaml | 9 +++++++++ README.md | 2 +- pyproject.toml | 8 +++++--- uv.lock | 15 +++++++++++++++ 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 369c819..198795f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -29,6 +29,9 @@ jobs: - name: Check formatting run: uv run ruff format --check . + - name: Type check + run: uv run pyright + # The e2e tier needs a running DSS; the `e2e` marker keeps it out of the # default run (pyproject.toml). - name: Test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2df5239..b4caa5c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,6 +25,15 @@ repos: - repo: local hooks: + # Types on every commit. `always_run`, because a change to pyproject.toml + # alone can loosen the check. Runs from the project venv, not a hook venv, + # so pyright sees the real dependencies. + - id: pyright + name: pyright + entry: uv run pyright + language: system + pass_filenames: false + always_run: true # The full test suite before code leaves the machine (CONVENTIONS.md). - id: pytest name: pytest diff --git a/README.md b/README.md index 74b66dd..6339be0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ uv sync uv run ruff check . && uv run ruff format --check . && uv run pytest ``` -Once per clone, install the git hooks. Lint runs on every commit; the test +Once per clone, install the git hooks. Lint and types run on every commit; the test suite runs before every push. CI runs the same checks, so `--no-verify` only moves a failure later. diff --git a/pyproject.toml b/pyproject.toml index e97714a..3841846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ packages = ["src/experience_api"] dev = [ "asgi-lifespan>=2.1.0", "pre-commit>=4.0.0", + "pyright>=1.1.400", "pytest>=9.1.1", "pytest-asyncio>=1.0.0", # Coverage is reported on every run (report-only, no gate) so regressions @@ -65,14 +66,15 @@ select = [ ban-relative-imports = "all" # --------------------------------------------------------------------------- -# Editors default to the system interpreter and then cannot resolve pytest or -# pydantic. This points them at the project venv. Type checking is not a gate; -# ruff is the only enforced tool. +# Static analysis. pyright in `standard` mode is a gate, in pre-commit and CI. +# `venvPath`/`venv` point editors and the CLI at the project venv, since both +# default to the system interpreter and then cannot resolve the dependencies. # --------------------------------------------------------------------------- [tool.pyright] venvPath = "." venv = ".venv" include = ["src", "tests"] +typeCheckingMode = "standard" # --------------------------------------------------------------------------- # Tests. The `e2e` marker is excluded from the default run: that tier needs a diff --git a/uv.lock b/uv.lock index 60da877..f033d48 100644 --- a/uv.lock +++ b/uv.lock @@ -189,6 +189,7 @@ dependencies = [ dev = [ { name = "asgi-lifespan" }, { name = "pre-commit" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -207,6 +208,7 @@ requires-dist = [ dev = [ { name = "asgi-lifespan", specifier = ">=2.1.0" }, { name = "pre-commit", specifier = ">=4.0.0" }, + { name = "pyright", specifier = ">=1.1.400" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -448,6 +450,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyright" +version = "1.1.414" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, +] + [[package]] name = "pytest" version = "9.1.1" From 73c0a8ac0fa267d520e52243facea2b9f8d79dc0 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:34:30 +0530 Subject: [PATCH 08/11] chore: vulture for dead code - Unused functions, classes, imports and variables fail every commit and CI. - A route's HTTP-verb decorator counts as a use, since FastAPI calls it, not our code. --- .github/workflows/run-tests.yml | 3 +++ .pre-commit-config.yaml | 7 +++++++ README.md | 2 +- pyproject.toml | 13 +++++++++++++ uv.lock | 11 +++++++++++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 198795f..d099ff7 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -32,6 +32,9 @@ jobs: - name: Type check run: uv run pyright + - name: Dead code + run: uv run vulture + # The e2e tier needs a running DSS; the `e2e` marker keeps it out of the # default run (pyproject.toml). - name: Test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b4caa5c..09008d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,6 +34,13 @@ repos: language: system pass_filenames: false always_run: true + # Dead code on every commit, whatever changed, for the same reason. + - id: vulture + name: vulture + entry: uv run vulture + language: system + pass_filenames: false + always_run: true # The full test suite before code leaves the machine (CONVENTIONS.md). - id: pytest name: pytest diff --git a/README.md b/README.md index 6339be0..9a657e1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ uv sync uv run ruff check . && uv run ruff format --check . && uv run pytest ``` -Once per clone, install the git hooks. Lint and types run on every commit; the test +Once per clone, install the git hooks. Lint, types and dead code run on every commit; the test suite runs before every push. CI runs the same checks, so `--no-verify` only moves a failure later. diff --git a/pyproject.toml b/pyproject.toml index 3841846..ec22280 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dev = [ # are visible without blocking a merge — see addopts below. "pytest-cov>=7.1.0", "ruff>=0.16.4", + "vulture>=2.14", ] # --------------------------------------------------------------------------- @@ -107,3 +108,15 @@ markers = [ "e2e: one turn against a real DSS; needs a running DSS", ] strict_markers = true + +# --------------------------------------------------------------------------- +# Dead code. vulture reports unused functions, classes, imports and variables, +# at its default confidence (60%). A false positive earns a whitelist entry, +# not a lower bar. +# --------------------------------------------------------------------------- +[tool.vulture] +paths = ["src", "tests"] +# FastAPI calls a decorated route; our code never names it. A router's HTTP-verb +# decorator therefore counts as a use. Extend this when another kind of +# decorated hook arrives (exception handlers, websockets). +ignore_decorators = ["@*.get", "@*.post", "@*.put", "@*.patch", "@*.delete"] diff --git a/uv.lock b/uv.lock index f033d48..bb41a37 100644 --- a/uv.lock +++ b/uv.lock @@ -194,6 +194,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "vulture" }, ] [package.metadata] @@ -213,6 +214,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "ruff", specifier = ">=0.16.4" }, + { name = "vulture", specifier = ">=2.14" }, ] [[package]] @@ -656,3 +658,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/64/87/d29c3edf5dae96555 wheels = [ { url = "https://files.pythonhosted.org/packages/4c/7b/7ca6956dd5fd8298682186782de4a33c507552bce206adf6b0f2a3596353/virtualenv-21.11.1-py3-none-any.whl", hash = "sha256:00596e6238363a1a69fd899a6e67365830be0b0bff9a9c83bdcad6e9aee3c419", size = 5485002, upload-time = "2026-09-23T15:01:04.935Z" }, ] + +[[package]] +name = "vulture" +version = "2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, +] From 22a1f77dfb6b88370e074adc6d4ec04ba9f1d4eb Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:34:49 +0530 Subject: [PATCH 09/11] chore: a whitelist for vulture's false positives - `vulture_whitelist.py` is one of vulture's paths, so a name it lists counts as used. - Ruff ignores B018 and F821 there; the file is outside pyright's `include`. - Ships empty; its docstring says how to add an entry. --- pyproject.toml | 11 ++++++++--- vulture_whitelist.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 vulture_whitelist.py diff --git a/pyproject.toml b/pyproject.toml index ec22280..7c9cbca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,10 @@ select = [ # and `from .adapters import x` inside a feature is the easiest way to break the # dependency rule. tests/test_boundaries.py resolves them anyway; this stops # them being written. +[tool.ruff.lint.per-file-ignores] +# The vulture whitelist is `_.name` lines by design (see its docstring). +"vulture_whitelist.py" = ["B018", "F821"] + [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" @@ -111,11 +115,12 @@ strict_markers = true # --------------------------------------------------------------------------- # Dead code. vulture reports unused functions, classes, imports and variables, -# at its default confidence (60%). A false positive earns a whitelist entry, -# not a lower bar. +# at its default confidence (60%). A false positive earns a line in +# `vulture_whitelist.py`, not a lower bar. The whitelist is one of the paths, so +# every name it mentions counts as used. # --------------------------------------------------------------------------- [tool.vulture] -paths = ["src", "tests"] +paths = ["src", "tests", "vulture_whitelist.py"] # FastAPI calls a decorated route; our code never names it. A router's HTTP-verb # decorator therefore counts as a use. Extend this when another kind of # decorated hook arrives (exception handlers, websockets). diff --git a/vulture_whitelist.py b/vulture_whitelist.py new file mode 100644 index 0000000..16c057f --- /dev/null +++ b/vulture_whitelist.py @@ -0,0 +1,11 @@ +"""Names vulture must not report as dead. + +One `_.` line per entry, with a comment saying who really uses it: a +framework hook, a pytest fixture found by name, a Protocol method only its +implementations define. `uv run vulture --make-whitelist` prints lines in this +format for everything it currently flags. Copy over only real false positives. + +To every other tool this file is a useless expression on an undefined name, by +design. Ruff ignores B018 and F821 here, and the file sits outside pyright's +`include`. +""" From 55d3ed9da59ecab89677f3952ba9300c1a6dc576 Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:35:04 +0530 Subject: [PATCH 10/11] docs: how to run and check the API --- README.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9a657e1..8ac77fe 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,38 @@ request, calls the DSS, and streams the answer back. ## Run it +Needs [uv](https://docs.astral.sh/uv/). It installs Python 3.13 itself. + ```bash uv sync -uv run ruff check . && uv run ruff format --check . && uv run pytest +uv run uvicorn --factory experience_api.app:create_app --port 8078 +``` + +The API listens on `http://localhost:8078`. + +| Path | What | +|---|---| +| `GET /healthz` | `{"status": "ok"}` when the process is up. For Docker and the proxy | +| `GET /docs` | The interactive OpenAPI page | + +```bash +curl -i http://localhost:8078/healthz +``` + +Add `--reload` to restart on every file change while developing. + +## Check it + +```bash +uv run ruff check . && uv run ruff format --check . # lint, format +uv run pyright # types +uv run vulture # dead code +uv run pytest # tests, with coverage ``` -Once per clone, install the git hooks. Lint, types and dead code run on every commit; the test -suite runs before every push. CI runs the same checks, so `--no-verify` only -moves a failure later. +Once per clone, install the git hooks. Lint, types and dead code run on every +commit; the test suite runs before every push. CI runs the same checks, so +`--no-verify` only moves a failure later. ```bash uv run pre-commit install --hook-type pre-commit --hook-type pre-push From 9f02d282c2529a1372e43131774df4683404555e Mon Sep 17 00:00:00 2001 From: RohanReddy44 Date: Wed, 23 Sep 2026 23:35:10 +0530 Subject: [PATCH 11/11] docs: a CHANGELOG with an Unreleased section --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1679104 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project are recorded here, in the format of +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow +`MAJOR.MINOR.PATCH`, as `CONVENTIONS.md` sets out. + +## [Unreleased] + +### Added +- The repo skeleton: uv, ruff, pyright, vulture, pytest, pre-commit and CI. +- `create_app()`, the composition root. +- `GET /healthz`, for Docker and the front proxy. +- A test that enforces the architecture's dependency rule.