Skip to content

Commit 2d6ccde

Browse files
committed
feat(analysis): add check_raises CI checker, AST walker and typer CLI
Layer 3: statically compare each route's declared Raises[...] against the errors it can actually raise (endpoint + helpers + dependency tree), reported as undeclared/overdeclared. Ships check_raises() plus a typer CLI behind the optional `cli` extra. Excludes the FastAPI 0.137-0.138 routing gap.
1 parent 5bdd2f6 commit 2d6ccde

15 files changed

Lines changed: 1702 additions & 12 deletions

File tree

CLAUDE.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ Public PyPI package: typed HTTP errors for FastAPI — exact `Literal` error cod
66

77
## Status
88

9-
Draft. The `core` and `decorator` layers are implemented. Linters/type checker and pytest are configured (see Conventions). Licensed under MIT (`LICENSE` + PEP 639 metadata in `pyproject.toml`).
9+
Draft. The `core` and `decorator` layers plus the layer-3 CI checker are implemented (the analysis `auto`-fill mode is not yet). Linters/type checker and pytest are configured (see Conventions); the whole package is at 100% branch coverage. Licensed under MIT (`LICENSE` + PEP 639 metadata in `pyproject.toml`).
1010

1111
## Architecture — three independent layers
1212

1313
Each layer is usable without the next one:
1414

1515
1. **`core`** (done) — `BaseError`, the `BaseErrorMeta` metaclass, `ErrorResponse`, `error_models()`, `handle_base_error`.
1616
2. **`decorator`** (done) — `with_errors(router)` + `Raises[...]` in the return annotation (Annotated syntax). An instance patch of `add_api_route` as the single interception point; subclassing `APIRouter` AND a wrapper object were both rejected (see decorator-layer decisions).
17-
3. **`analysis`** (planned) — AST walk over `raise` statements: a CI checker (declared vs actually raised) and `auto=True` (auto-populating `responses`). Study fastapi-docx before implementing.
17+
3. **`analysis`** (CI checker done; `auto` planned) — AST walk over `raise` statements. `check_raises()` compares declared `Raises[...]` against errors actually raised (endpoint + helpers + dependency tree); a typer CLI (`cli` extra) wraps it. `auto=True` (auto-populating `responses` from the same walk) is the remaining piece. Design inspired by fastapi-docx (MIT), whose flaws are deliberately not inherited (see analysis-layer decisions).
1818

1919
## Layout
2020

@@ -25,16 +25,19 @@ src-layout, package `src/fastapi_typed_errors/`:
2525
- `core/handlers.py``handle_base_error`.
2626
- `decorator/raises.py` — the `Raises` marker (validated at construction).
2727
- `decorator/wrapper.py``with_errors()` + the `add_api_route` patch and private helpers.
28+
- `analysis/visitor.py``collect_raised()` + the AST worklist (`_collect`/`_scan`/`_BodyVisitor`/`_resolve`).
29+
- `analysis/checker.py``check_raises()` + `RaisesReport`/`RouteDiscrepancy`.
30+
- `analysis/cli.py``main()` launcher (no top-level `typer` import); `analysis/_cli.py` — the typer app (lazy-loaded).
2831
- `__init__.py` / subpackage `__init__.py` — public API re-exports.
2932
- `py.typed` — the package is typed.
3033

3134
## Key core-layer decisions
3235

33-
- Python ≥ 3.12 (PEP 695 generics); dependencies: fastapi 0.115, pydantic ≥ 2.9 (`model_title_generator`).
36+
- Python ≥ 3.12 (PEP 695 generics); dependencies: fastapi `>=0.115,!=0.137.*,!=0.138.*` (routing-gap exclusion, see analysis-layer decisions), pydantic ≥ 2.9 (`model_title_generator`).
3437
- The user brings their own enum: a code is any `StrEnum` member **or** a bare `Literal["CODE"]` (the `T: str` bound covers both).
3538
- The metaclass extracts the code from `__orig_bases__` (`get_origin`/`get_args`), filtering bases via `isinstance(get_origin(orig_base), mcs)`; it writes `error_code` and `model` into the namespace **before** `type.__new__` — otherwise the metaclass properties would intercept the assignment.
3639
- Eager validation: parametrizing with anything but a `TypeVar` (intermediate generic base) or a single-string `Literal` raises `TypeError` at class definition time — a mistake like `BaseError[str]` must not surface as an opaque 500 at request time.
37-
- `response_base: ClassVar` — a substitutable response model base; users subclass `ErrorResponse` generically (`class MyResp[T: str](ErrorResponse[T])`) and point their own error base class at it.
40+
- `response_base: ClassVar` — a substitutable response model base; users subclass `ErrorResponse` generically (`class MyResp[T: str](ErrorResponse[T])`) and point their own error base class at it. **Flat extensions only** (user decision 2026-07-22): nested envelopes (`data: ErrorResponse[T]`) are out of scope — pydantic discriminated unions cannot discriminate on a nested field, so `error_models()` unions would break; the workaround (non-subclass `response_base` + overridden `to_response()`) exists but is deliberately undocumented.
3841
- `description: ClassVar[str | None]` — the default `detail` and the OpenAPI status description (consumed by the decorator layer).
3942
- OpenAPI titles: `model_title_generator` (`_model_title`) renders `ErrorResponse[NOT_FOUND]` instead of pydantic's default that embeds the enum member `repr()` with angle brackets.
4043
- `error_models()`: deduplicates repeats; one code shared by two distinct models → a clear `TypeError` (a discriminated union requires unique discriminator values).
@@ -57,6 +60,18 @@ src-layout, package `src/fastapi_typed_errors/`:
5760
- Unresolvable type hints (NameError/TypeError from `get_type_hints`): if the raw return annotation does not mention `Raises` → silent passthrough (stock FastAPI tolerates the `TYPE_CHECKING` pattern and never resolves return hints under an explicit `response_model`); if it does → fail fast with a clear `TypeError`. Never resolve stricter than stock for marker-free endpoints.
5861
- Follow-up idea (not implemented): PEP 692 `Unpack[TypedDict]` typing for registration kwargs.
5962

63+
## Key analysis-layer decisions
64+
65+
- **FastAPI routing gap excluded at the dependency level** (user decision 2026-07-23): `fastapi>=0.115,!=0.137.*,!=0.138.*`. 0.137 introduced the lazy `_IncludedRouter` tree (PR #15745) but not the supported `iter_route_contexts` iterator (landed 0.139), so nested includes cannot be walked on 0.137–0.138. Two clean regimes remain: ≤0.136 eager-flat `routes`, ≥0.139 lazy with `iter_route_contexts`.
66+
- Route enumeration: `getattr(fastapi.routing, "iter_route_contexts", None)` looked up **at call time** (both branches monkeypatchable/coverable); absent ⟹ eager regime ⟹ flat `target.routes`. Filter `isinstance(original_route, APIRoute)`; dedup by `id(original_route)` (multi-prefix include yields the same route twice).
67+
- Declared set is re-derived from the endpoint via the decorator's `_unwrap_endpoint`/`_return_annotation`/`_find_raises` (imported from `..decorator.wrapper`) — **not** from `route.response_model` (lost under explicit `response_model=` and our Response/None normalization). Works with or without `with_errors`.
68+
- Raised set = `collect_raised(endpoint)``collect_raised(dep.call)` over the recursive `route.dependant` tree, **skipping security schemes** (`isinstance(inspect.unwrap(peeled call), SecurityBase)` — they raise stock `HTTPException`, never `BaseError`).
69+
- Walker design (avoids fastapi-docx's flaws): BFS worklist + **pure per-function scan** (`_scan` depends only on the code object → cache keyed by `__code__` is depth-safe; resolution is per-object so closures resolve correctly). Visited-set + `max_depth` cap kill recursion (fastapi-docx has neither). Real `issubclass` + `(error_code, http_status)` probe instead of name-matching. Never `eval()`s — status/code live on the class.
70+
- `_BodyVisitor` descends into statement bodies only (skips annotations/decorators/defaults), so a route's own `Raises[...]` return annotation is not counted, and local nested `def`s ARE walked. Catches `raise X`, `raise X(...)`, `raise ... from e`. **Argument heuristic**: an error class passed as a call argument counts as potentially raised (the `get_or_404(error=X)` pattern) — carve-out for `isinstance`/`issubclass`. Over-approximation is safe for CI (surfaces as `undeclared`); documented false-negatives (locals, `self.*`, dynamic dispatch, bare streams) keep `overdeclared` a failure by default.
71+
- Two discrepancy buckets, reported separately: `undeclared` (always a failure), `overdeclared` (failure by default; `allow_overdeclared=True` strips it at report-build time). `RaisesReport.ok` = `not routes`; no `__bool__` (ambiguous).
72+
- CLI split: `cli.py::main()` is the console-script entry with **no top-level `typer` import** (degrades to exit 2 + install hint without the `cli` extra); `_cli.py` holds the typer app (module-level `typer.Typer()`, needs the extra). A `@cli.callback()` forces `check` to be a named subcommand (a single typer command would otherwise collapse and swallow the app-path argument). Exit codes: 0 ok, 1 discrepancies, 2 usage/loading.
73+
- `collect_raised`/`_scan` are the reuse point for the future `auto=True`.
74+
6075
## Conventions
6176

6277
- This file and all code artifacts (docstrings, comments) are in English; communication with the user is in Russian.
@@ -69,8 +84,8 @@ src-layout, package `src/fastapi_typed_errors/`:
6984
- Type checker: **ty**, max strictness (`[tool.ty.rules] all = "error"`). Suppressions use ty-style comments (`# ty: ignore[rule]`); ty does not recognize mypy rule codes inside `# type: ignore[...]`.
7085
- Linter/formatter: **ruff** with `select = ["ALL"]` + `preview = true`, ignoring only the `TC` and `CPY` modules and `missing-trailing-comma` (COM812, formatter conflict); pydocstyle convention = google. Formatter: double quotes, `line-length = 120`. Import order: ruff isort defaults (stdlib `import` then `from` imports, third-party, local) — exactly the preferred style, no extra config needed.
7186
- Ruff suppressions use the new `# ruff:ignore[rule-name]` syntax — the preview rule `noqa-comments` forbids legacy `# noqa` comments. Single-rule entries in `lint.ignore` must use rule *names*, not codes (preview rule `rule-codes-in-selectors`).
72-
- ruff and ty are pinned exactly (`==`) in the `dev` dependency group; bump deliberately (`uv add --dev --bounds exact ty ruff`). Test tooling (`pytest`, `pytest-cov`, `httpx2` for `TestClient`) uses compatible-release pins (`~=`).
73-
- Tests live in `tests/`, mirroring the package structure (`tests/core/test_base.py``src/.../core/base.py`); the directory is not a package (INP001 ignored, S101 too: pytest asserts; EM101 ignored — literal details in `raise` are the package's own user-facing pattern). Run: `just test` (`uv run pytest --cov`); pytest config and coverage live in `pyproject.toml`. **Coverage bar: `fail_under = 100`** (branch coverage on). Test-writing rules are introduced incrementally by the user — check recent test files for the current style before writing new ones.
87+
- ruff and ty are pinned exactly (`==`) in the `dev` dependency group; bump deliberately (`uv add --dev --bounds exact ty ruff`). Test tooling (`pytest`, `pytest-cov`, `httpx2` for `TestClient`, `anyio`, `typer` for the CLI tests) uses compatible-release pins (`~=`). The CLI itself is an optional `[project.optional-dependencies] cli` extra (`typer>=0.15`); the `fastapi-typed-errors` console script is `analysis.cli:main`.
88+
- Tests live in `tests/`, mirroring the package structure (`tests/core/test_base.py``src/.../core/base.py`); the directory is not a package (per-file ignores for `tests/**`: INP001, S101, EM101, plus PLR2004 `magic-value-comparison` and PLC2701 `import-private-name` — tests compare to literal counts and exercise private internals). Non-`test_*` fixture modules (`tests/analysis/walker_helpers.py`, `cli_apps.py`) are importable helpers pytest does not collect; the CLI test `chdir`s into the test dir so the checker's `sys.path`-insert finds `cli_apps`. Run: `just test` (`uv run pytest --cov`); pytest config and coverage live in `pyproject.toml`. **Coverage bar: `fail_under = 100`** (branch coverage on). Test-writing rules are introduced incrementally by the user — check recent test files for the current style before writing new ones.
7489
- Async tests are plain `async def` — the anyio pytest plugin picks them up via `tests/conftest.py` (an `anyio_backend` fixture + a `pytest_collection_modifyitems` hook auto-marking coroutine tests; anyio has no pytest-asyncio-style "auto" mode).
7590
- Registration-only stub endpoints in tests end with `raise NotImplementedError` (ty `all = "error"` rejects `...` bodies with non-`None` return annotations outside stubs/protocols).
7691
- Test structure: **AAA** (Arrange / Act / Assert), the blocks separated by blank lines; single-expression tests may collapse to one line (e.g. `assert error_models(X) is X.model`). Every test opens with a short one-line docstring describing the case; test functions are annotated `-> None`.

README.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
Typed error responses for FastAPI: `Literal` error codes in OpenAPI, discriminated `oneOf` unions, and a single source of truth — the error class itself.
44

5-
> **Status: draft.** Layers 1 (`core`) and 2 (`decorator`) are implemented; the AST analysis layer (CI checker + `auto`) is coming.
5+
> **Status: draft.** Layers 1 (`core`), 2 (`decorator`) and the layer-3 CI checker are implemented; the analysis `auto`-fill mode is coming.
66
77
## Requirements
88

99
- Python **3.12+** (PEP 695 generics)
10-
- FastAPI ≥ 0.115, Pydantic ≥ 2.9
10+
- FastAPI ≥ 0.115 (the lazy-routing gap `0.137``0.138` is excluded), Pydantic ≥ 2.9
11+
- The CI checker CLI needs the `cli` extra: `pip install 'fastapi-typed-errors[cli]'`
1112

1213
## Quick start
1314

@@ -83,6 +84,31 @@ The decorator layer is optional sugar — `responses={}` can always be filled by
8384
def get_item(item_id: int) -> Item: ...
8485
```
8586

87+
## CI checker
88+
89+
`check_raises` statically compares what each route **declares** via `Raises[...]` against what it can actually **raise** — in the endpoint, its helpers and its whole dependency tree. It closes the gap that pure annotations leave open: a raise you forgot to declare, or a declaration you no longer raise.
90+
91+
```python
92+
from fastapi_typed_errors import check_raises
93+
94+
95+
def test_error_contracts() -> None:
96+
report = check_raises(app) # a FastAPI app or an APIRouter
97+
assert report.ok, report.routes
98+
```
99+
100+
`report.routes` lists each `RouteDiscrepancy` with two independent buckets: `undeclared` (raised in code, missing from `Raises` — always a failure) and `overdeclared` (declared but never found raised). Pass `allow_overdeclared=True` to ignore the second bucket for code whose raises the static walker cannot see.
101+
102+
The same check as a CLI (needs the `cli` extra), pointed at a `module:attribute` app path:
103+
104+
```console
105+
$ fastapi-typed-errors check app.main:app
106+
```
107+
108+
Exit codes: `0` all declarations match, `1` discrepancies (rendered as a table), `2` a usage/loading error. Flags: `--allow-overdeclared`, `--max-depth`.
109+
110+
The walker follows the `get_or_404(error=NotFoundError)` factory pattern (error classes passed as call arguments), closures, cross-module helpers and `functools.partial`; it stays within one process and never executes your code. Out of scope (documented false-negatives, so `overdeclared` stays a failure by default): errors raised through local-variable indirection, `self.method()` chains, dynamic dispatch, and bare `AsyncIterator` stream endpoints.
111+
86112
## Notes
87113

88114
- Error codes are any `StrEnum` members you bring, or plain strings: `BaseError[Literal["NOT_FOUND"]]`.
@@ -93,8 +119,8 @@ def get_item(item_id: int) -> Item: ...
93119
- Unresolvable return annotations without `Raises` (the `if TYPE_CHECKING:` import pattern) pass through untouched, exactly like stock FastAPI; with `Raises` mentioned they fail fast with a clear `TypeError`.
94120
- `-> Annotated[Response subclass, Raises[...]]` and `-> Annotated[None, Raises[...]]` are normalized to `response_model=None`, restoring stock FastAPI semantics for raw-response and empty routes.
95121
- Bare stream returns (`-> Annotated[AsyncIterator[X], Raises[...]]`, the SSE/JSONL feature) are not supported — annotate a `Response` subclass instead.
96-
- `Raises` metadata on a router that was **not** passed through `with_errors` is inert — nothing is injected and nothing fails (the upcoming analysis layer will catch such drift).
122+
- `Raises` metadata on a router that was **not** passed through `with_errors` is inert — nothing is injected and nothing fails; the [CI checker](#ci-checker) reads annotations directly, so it still catches such drift.
97123
- Status descriptions come from each error's `description`; several errors on one status get their descriptions joined with `;`, and the HTTP status phrase is the fallback.
98-
- Customize the response body by overriding `response_base` with your own generic subclass of `ErrorResponse`.
124+
- Customize the response body by overriding `response_base` with your own generic subclass of `ErrorResponse` — extra fields are added alongside `code`/`detail` (e.g. `status: Literal["error"] = "error"`). The shape must stay **flat**: nested envelopes like `{"status": ..., "data": {"code": ...}}` are not supported, because pydantic discriminated unions require the `code` discriminator at the top level of the model.
99125
- `BaseError` subclasses `fastapi.HTTPException`; mixing it with `ABC` and other custom-metaclass bases raises a metaclass conflict. The metaclass is public for exactly this case — build a combined one: `class Meta(BaseErrorMeta, ABCMeta): ...` (`from fastapi_typed_errors.core.base import BaseErrorMeta`; deliberately not re-exported from the package root).
100126
- The handler is registered explicitly — the same way you call `app.add_middleware(...)`; the package does not touch your app behind your back. `handle_base_error` is typed `(Request, Exception)` to match Starlette's handler contract, so the registration line stays clean under every type checker; registering it for a non-`BaseError` exception type fails fast with a `TypeError` at runtime.

pyproject.toml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,18 @@ license = "MIT"
1111
license-files = ["LICENSE"]
1212
keywords = ["fastapi", "errors", "openapi", "typed", "pydantic"]
1313
dependencies = [
14-
"fastapi>=0.115",
14+
# 0.137/0.138 introduced the lazy _IncludedRouter tree but not the supported
15+
# iter_route_contexts iterator — nested includes cannot be walked there; skip that gap.
16+
"fastapi>=0.115,!=0.137.*,!=0.138.*",
1517
"pydantic>=2.9",
1618
]
1719

20+
[project.optional-dependencies]
21+
cli = ["typer>=0.15"]
22+
23+
[project.scripts]
24+
fastapi-typed-errors = "fastapi_typed_errors.analysis.cli:main"
25+
1826
[build-system]
1927
requires = ["uv_build>=0.11.30,<0.12.0"]
2028
build-backend = "uv_build"
@@ -43,6 +51,8 @@ convention = "google"
4351
"assert", # S101: pytest is built on assert
4452
"implicit-namespace-package", # INP001: the tests directory is not a package
4553
"raw-string-in-exception", # EM101: raising errors with a literal detail is the package's own user-facing pattern
54+
"magic-value-comparison", # PLR2004: comparing to literal counts/exit codes is normal in tests
55+
"import-private-name", # PLC2701: tests exercise the package's private internals
4656
]
4757

4858
[tool.ty.rules]
@@ -68,4 +78,5 @@ dev = [
6878
"pytest-cov~=7.1.0",
6979
"ruff==0.15.22",
7080
"ty==0.0.61",
81+
"typer~=0.27.0",
7182
]

src/fastapi_typed_errors/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
source of truth: the error class itself.
55
"""
66

7+
from .analysis import RaisesReport, RouteDiscrepancy, check_raises
78
from .core import (
89
BaseError,
910
ErrorResponse,
@@ -16,6 +17,9 @@
1617
"BaseError",
1718
"ErrorResponse",
1819
"Raises",
20+
"RaisesReport",
21+
"RouteDiscrepancy",
22+
"check_raises",
1923
"error_models",
2024
"handle_base_error",
2125
"with_errors",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Layer 3 — static analysis: check declared ``Raises`` against raises in code."""
2+
3+
from .checker import RaisesReport, RouteDiscrepancy, check_raises
4+
from .visitor import collect_raised
5+
6+
__all__ = (
7+
"RaisesReport",
8+
"RouteDiscrepancy",
9+
"check_raises",
10+
"collect_raised",
11+
)

0 commit comments

Comments
 (0)