You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: CLAUDE.md
+21-6Lines changed: 21 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,15 +6,15 @@ Public PyPI package: typed HTTP errors for FastAPI — exact `Literal` error cod
6
6
7
7
## Status
8
8
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`).
10
10
11
11
## Architecture — three independent layers
12
12
13
13
Each layer is usable without the next one:
14
14
15
15
1.**`core`** (done) — `BaseError`, the `BaseErrorMeta` metaclass, `ErrorResponse`, `error_models()`, `handle_base_error`.
16
16
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).
- The user brings their own enum: a code is any `StrEnum` member **or** a bare `Literal["CODE"]` (the `T: str` bound covers both).
35
38
- 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.
36
39
- 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.
38
41
-`description: ClassVar[str | None]` — the default `detail` and the OpenAPI status description (consumed by the decorator layer).
39
42
- 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.
40
43
-`error_models()`: deduplicates repeats; one code shared by two distinct models → a clear `TypeError` (a discriminated union requires unique discriminator values).
- 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.
58
61
- Follow-up idea (not implemented): PEP 692 `Unpack[TypedDict]` typing for registration kwargs.
59
62
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
+
60
75
## Conventions
61
76
62
77
- This file and all code artifacts (docstrings, comments) are in English; communication with the user is in Russian.
- 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[...]`.
70
85
- 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.
71
86
- 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.
74
89
- 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).
75
90
- Registration-only stub endpoints in tests end with `raise NotImplementedError` (ty `all = "error"` rejects `...` bodies with non-`None` return annotations outside stubs/protocols).
76
91
- 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`.
Copy file name to clipboardExpand all lines: README.md
+30-4Lines changed: 30 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,12 +2,13 @@
2
2
3
3
Typed error responses for FastAPI: `Literal` error codes in OpenAPI, discriminated `oneOf` unions, and a single source of truth — the error class itself.
4
4
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.
6
6
7
7
## Requirements
8
8
9
9
- 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]'`
11
12
12
13
## Quick start
13
14
@@ -83,6 +84,31 @@ The decorator layer is optional sugar — `responses={}` can always be filled by
83
84
defget_item(item_id: int) -> Item: ...
84
85
```
85
86
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
+
deftest_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
+
86
112
## Notes
87
113
88
114
- Error codes are any `StrEnum` members you bring, or plain strings: `BaseError[Literal["NOT_FOUND"]]`.
- 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`.
94
120
-`-> Annotated[Response subclass, Raises[...]]` and `-> Annotated[None, Raises[...]]` are normalized to `response_model=None`, restoring stock FastAPI semantics for raw-response and empty routes.
95
121
- 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.
97
123
- 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.
99
125
-`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).
100
126
- 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.
0 commit comments