Skip to content

Commit d0f1360

Browse files
committed
feat(decorator): add auto error discovery to with_errors
with_errors(router, auto=True) fills responses by statically walking each endpoint and its whole dependency tree at registration (the same walk as check_raises), merged with any explicit Raises[...]. The Dependant tree is rebuilt via public get_dependant/get_parameterless_sub_dependant with a lazy import + endpoint-only fallback; _dependency_calls/_is_security_scheme move to decorator.wrapper as the shared layer the checker imports from.
1 parent 2d6ccde commit d0f1360

5 files changed

Lines changed: 284 additions & 52 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 3 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 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`).
9+
Draft. All three layers are implemented — `core`, the `with_errors` decorator (with `auto`-fill), and the layer-3 analysis (CI checker `check_raises` + CLI). 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`** (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).
17+
3. **`analysis`** (done) — 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. `with_errors(router, auto=True)` reuses the same walk to auto-populate `responses` at registration. Design inspired by fastapi-docx (MIT), whose flaws are deliberately not inherited (see analysis-layer decisions).
1818

1919
## Layout
2020

@@ -70,7 +70,9 @@ src-layout, package `src/fastapi_typed_errors/`:
7070
- `_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.
7171
- 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).
7272
- 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`.
73+
- `collect_raised`/`_scan` are the shared engine for both `check_raises` and `with_errors(auto=True)`.
74+
- **`auto=True`** (user decision 2026-07-23): scope = endpoint + full dependency tree (matches `check_raises`); the discovered set is **unioned** with any `Raises[...]` markers, and an explicit `responses={}` still wins per status. Injected in `wrapper` before delegating (mutating `route.responses` after build silently drops the schema — `response_fields` are frozen at build; verified). The dependency tree is rebuilt at registration (the route does not exist yet) via public `fastapi.dependencies.utils.get_dependant` + `get_parameterless_sub_dependant`, folding router-level + route-level `dependencies=[...]` (mirror `_build_dependant_with_parameterless_dependencies`); `path` is irrelevant (only path-param detection). `auto` is captured in the wrapper closure — first-`with_errors`-call wins (idempotent re-wrap does not change it).
75+
- Import layering for `auto`: `decorator.wrapper` cannot import `analysis` at module level (analysis already imports wrapper → cycle). So `collect_raised` is **lazy-imported** inside `_auto_raised`, and `get_dependant`/`get_parameterless_sub_dependant` are lazy-imported in a `try/except ImportError` → graceful degradation to endpoint-only if FastAPI moves them (tested by `delattr`-ing `get_parameterless_sub_dependant`, which fails only our combined import, not FastAPI's own bindings). `_dependency_calls`/`_is_security_scheme` live in `decorator.wrapper` (the shared lower layer both use) and are imported by `analysis.checker`.
7476

7577
## Conventions
7678

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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`), 2 (`decorator`) and the layer-3 CI checker are implemented; the analysis `auto`-fill mode is coming.
5+
> **Status: draft.** All three layers are implemented — `core`, the `with_errors` decorator (with `auto`-fill), and the layer-3 CI checker.
66
77
## Requirements
88

@@ -69,6 +69,21 @@ OpenAPI gets a `404` and a `403` entry with the **exact** `Literal` code each; s
6969

7070
`with_errors(router)` returns the **same** `APIRouter` instance with its `add_api_route` patched on the instance, so object identity is preserved: `include_router`, websockets, imperative `add_api_route(...)` calls and app-level decorators all work natively. For an application, wrap its router: `with_errors(app.router)`.
7171

72+
### Auto-fill
73+
74+
Pass `with_errors(router, auto=True)` to drop the `Raises[...]` markers entirely: at registration each endpoint and its whole dependency tree are statically walked (the same walk the [CI checker](#ci-checker) uses), and the discovered errors fill `responses` automatically — merged with any markers you *do* write, and an explicit `responses={}` still wins per status.
75+
76+
```python
77+
router = with_errors(APIRouter(), auto=True)
78+
79+
80+
@router.get("/items/{item_id}")
81+
def get_item(item_id: int, user: Annotated[User, Depends(current_user)]) -> Item:
82+
if item_id == 0:
83+
raise NotFoundError(f"No item {item_id}") # auto -> 404
84+
return Item(item_id=item_id) # + whatever current_user can raise
85+
```
86+
7287
## Core layer only
7388

7489
The decorator layer is optional sugar — `responses={}` can always be filled by hand with `error_models()`:

src/fastapi_typed_errors/analysis/checker.py

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""CI checker: compare declared ``Raises`` against the errors found in code."""
22

3-
import functools
4-
import inspect
53
from collections.abc import Callable, Iterator
64
from dataclasses import dataclass
75
from typing import Any, Protocol, cast
@@ -10,10 +8,14 @@
108
from fastapi import APIRouter, FastAPI
119
from fastapi.dependencies.models import Dependant
1210
from fastapi.routing import APIRoute
13-
from fastapi.security.base import SecurityBase
1411

1512
from ..core.base import BaseError
16-
from ..decorator.wrapper import _find_raises, _return_annotation, _unwrap_endpoint
13+
from ..decorator.wrapper import (
14+
_dependency_calls,
15+
_find_raises,
16+
_return_annotation,
17+
_unwrap_endpoint,
18+
)
1719
from .visitor import _collect, _ScanCache
1820

1921

@@ -164,37 +166,6 @@ def _raised(route: _RouteLike, *, max_depth: int, cache: _ScanCache) -> frozense
164166
return frozenset(raised)
165167

166168

167-
def _dependency_calls(dependant: Dependant) -> Iterator[Callable[..., Any]]:
168-
"""Yield every dependency callable in the tree, skipping security schemes.
169-
170-
Args:
171-
dependant: The route's dependency tree root.
172-
173-
Yields:
174-
Callable[..., Any]: Each sub-dependency's callable.
175-
"""
176-
for sub in dependant.dependencies:
177-
if sub.call is not None and not _is_security_scheme(sub.call):
178-
yield sub.call
179-
yield from _dependency_calls(sub)
180-
181-
182-
def _is_security_scheme(call: Callable[..., Any]) -> bool:
183-
"""Report whether a dependency callable is a security scheme.
184-
185-
Args:
186-
call: The dependency callable.
187-
188-
Returns:
189-
bool: ``True`` for ``SecurityBase`` instances (they raise stock
190-
``HTTPException``, never ``BaseError``).
191-
"""
192-
unwrapped = call
193-
while isinstance(unwrapped, functools.partial):
194-
unwrapped = unwrapped.func
195-
return isinstance(inspect.unwrap(unwrapped), SecurityBase)
196-
197-
198169
def _sorted(errors: frozenset[type[BaseError[Any]]]) -> tuple[type[BaseError[Any]], ...]:
199170
"""Order error classes by name for deterministic output.
200171

src/fastapi_typed_errors/decorator/wrapper.py

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
import functools
44
import inspect
5-
from collections.abc import Callable
5+
from collections.abc import Callable, Iterator, Sequence
66
from http import HTTPStatus
77
from types import UnionType
88
from typing import Annotated, Any, Final, TypeAliasType, Union, cast, get_args, get_origin, get_type_hints
99

1010
from fastapi import APIRouter, Response
1111
from fastapi.datastructures import DefaultPlaceholder
12+
from fastapi.dependencies.models import Dependant
13+
from fastapi.params import Depends
14+
from fastapi.security.base import SecurityBase
1215

1316
from ..core.base import BaseError
1417
from ..core.models import error_models
@@ -17,7 +20,7 @@
1720
_ALREADY_WRAPPED: Final[str] = "_fastapi_typed_errors_wrapped"
1821

1922

20-
def with_errors[R: APIRouter](router: R, /) -> R:
23+
def with_errors[R: APIRouter](router: R, /, *, auto: bool = False) -> R:
2124
"""Enable ``Raises`` handling on the router and return the same router.
2225
2326
The router's ``add_api_route`` is replaced (on the instance) with a wrapper
@@ -30,28 +33,34 @@ def with_errors[R: APIRouter](router: R, /) -> R:
3033
patch. Idempotent: wrapping twice is a no-op.
3134
3235
For an application, wrap its router: ``with_errors(app.router)``.
33-
Future options will be keyword-only (e.g. ``with_errors(router, auto=...)``).
3436
3537
Args:
3638
router: The router to enable ``Raises`` handling on.
39+
auto: When ``True``, also fill ``responses`` from errors discovered by
40+
statically walking each endpoint and its whole dependency tree — no
41+
``Raises[...]`` needed; discovered errors are merged with any that
42+
are declared. Set it on the first ``with_errors`` call; because
43+
wrapping is idempotent, a later call does not change it.
3744
3845
Returns:
3946
R: The same router instance, for chaining and assignment.
4047
"""
4148
if not getattr(router.add_api_route, _ALREADY_WRAPPED, False):
42-
router.add_api_route = _wrap_add_api_route(router.add_api_route) # ty: ignore[invalid-assignment]
49+
router.add_api_route = _wrap_add_api_route(router.add_api_route, router, auto=auto) # ty: ignore[invalid-assignment]
4350
return router
4451

4552

46-
def _wrap_add_api_route[**P, R](add_api_route: Callable[P, R], /) -> Callable[P, R]:
53+
def _wrap_add_api_route[**P, R](add_api_route: Callable[P, R], router: APIRouter, /, *, auto: bool) -> Callable[P, R]:
4754
"""Build the ``add_api_route`` replacement enriching ``responses`` from ``Raises``.
4855
4956
The wrapper only manipulates call arguments and delegates: without markers
50-
the call passes through byte-for-byte, so FastAPI's ``DefaultPlaceholder``
51-
sentinels and inference behavior stay intact.
57+
(and without ``auto``) the call passes through byte-for-byte, so FastAPI's
58+
``DefaultPlaceholder`` sentinels and inference behavior stay intact.
5259
5360
Args:
5461
add_api_route: The original bound ``APIRouter.add_api_route``.
62+
router: The wrapped router (its ``dependencies`` feed the ``auto`` walk).
63+
auto: Whether to also derive errors from the endpoint and dependencies.
5564
5665
Returns:
5766
Callable[P, R]: The replacement, preserving the original signature.
@@ -65,13 +74,15 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
6574
# Let the original signature produce the natural error.
6675
return add_api_route(*args, **kwargs)
6776
annotation = _return_annotation(_unwrap_endpoint(endpoint), path)
68-
markers = _find_raises(annotation)
69-
if markers:
77+
errors = dict.fromkeys(error for marker in _find_raises(annotation) for error in marker.errors)
78+
if auto:
79+
route_dependencies = cast(Sequence[Depends] | None, kwargs.get("dependencies"))
80+
errors = dict.fromkeys([*errors, *_auto_raised(endpoint, path, route_dependencies, router)])
81+
if errors:
7082
# Mutating P.kwargs violates the ParamSpec guarantee formally, but the keys
7183
# belong to the wrapped signature — localize the lie in one cast alias.
7284
raw_kwargs = cast(dict[str, Any], kwargs)
73-
errors = tuple(dict.fromkeys(error for marker in markers for error in marker.errors))
74-
raw_kwargs["responses"] = {**_build_responses(errors), **(raw_kwargs.get("responses") or {})}
85+
raw_kwargs["responses"] = {**_build_responses(tuple(errors)), **(raw_kwargs.get("responses") or {})}
7586
if (model := raw_kwargs.get("response_model")) is None or isinstance(model, DefaultPlaceholder):
7687
base = _annotated_base(annotation)
7788
if base is type(None) or (isinstance(base, type) and issubclass(base, Response)):
@@ -219,3 +230,79 @@ def _build_responses(errors: tuple[type[BaseError[Any]], ...]) -> dict[int | str
219230
"description": "; ".join(descriptions) or HTTPStatus(status).phrase,
220231
}
221232
return responses
233+
234+
235+
def _auto_raised(
236+
endpoint: Callable[..., Any],
237+
path: object,
238+
route_dependencies: Sequence[Depends] | None,
239+
router: APIRouter,
240+
) -> frozenset[type[BaseError[Any]]]:
241+
"""Discover the errors an endpoint and its dependency tree can raise.
242+
243+
Walks the endpoint's source with the analysis layer's ``collect_raised``
244+
and does the same for every dependency callable, rebuilding the ``Dependant``
245+
tree at registration time (the route does not exist yet). ``collect_raised``
246+
is imported lazily to break the ``decorator`` ↔ ``analysis`` import cycle;
247+
if FastAPI's dependency helpers move, the walk degrades to the endpoint only.
248+
249+
Args:
250+
endpoint: The endpoint callable being registered.
251+
path: The route path (only used for FastAPI's path-param detection,
252+
irrelevant to which dependencies are found).
253+
route_dependencies: The route-level ``dependencies=[Depends(...)]``.
254+
router: The router (its router-level ``dependencies`` are folded in).
255+
256+
Returns:
257+
frozenset[type[BaseError[Any]]]: Every ``BaseError`` subclass found.
258+
"""
259+
from ..analysis.visitor import collect_raised # ruff:ignore[import-outside-top-level] — breaks the import cycle
260+
261+
raised = set(collect_raised(endpoint))
262+
try:
263+
from fastapi.dependencies.utils import ( # ruff:ignore[import-outside-top-level] — enables the fallback below
264+
get_dependant,
265+
get_parameterless_sub_dependant,
266+
)
267+
except ImportError:
268+
return frozenset(raised)
269+
path_str = cast("str", path)
270+
dependant = get_dependant(path=path_str, call=endpoint)
271+
for depends in reversed([*router.dependencies, *(route_dependencies or [])]):
272+
dependant.dependencies.insert(0, get_parameterless_sub_dependant(depends=depends, path=path_str))
273+
for call in _dependency_calls(dependant):
274+
raised |= collect_raised(call)
275+
return frozenset(raised)
276+
277+
278+
def _dependency_calls(dependant: Dependant) -> Iterator[Callable[..., Any]]:
279+
"""Yield every dependency callable in the tree, skipping security schemes.
280+
281+
Shared with the analysis checker (which imports it from here).
282+
283+
Args:
284+
dependant: The dependency tree root.
285+
286+
Yields:
287+
Callable[..., Any]: Each sub-dependency's callable.
288+
"""
289+
for sub in dependant.dependencies:
290+
if sub.call is not None and not _is_security_scheme(sub.call):
291+
yield sub.call
292+
yield from _dependency_calls(sub)
293+
294+
295+
def _is_security_scheme(call: Callable[..., Any]) -> bool:
296+
"""Report whether a dependency callable is a security scheme.
297+
298+
Args:
299+
call: The dependency callable.
300+
301+
Returns:
302+
bool: ``True`` for ``SecurityBase`` instances (they raise stock
303+
``HTTPException``, never ``BaseError``).
304+
"""
305+
unwrapped = call
306+
while isinstance(unwrapped, functools.partial):
307+
unwrapped = unwrapped.func
308+
return isinstance(inspect.unwrap(unwrapped), SecurityBase)

0 commit comments

Comments
 (0)