Skip to content

Commit 8ebce91

Browse files
committed
fix(decorator): unwrap type-alias Raises markers in Annotated metadata
`_find_raises` only matched `Raises` markers that appear directly in an `Annotated` metadata slot. A marker shared through a PEP 695 `type` alias (`type Common = Raises[...]`) sits there as a `TypeAliasType`, so `isinstance(meta, Raises)` failed and the marker was dropped silently. Add a shared `_unwrap_type_alias` helper and apply it to each metadata element before the checks (and reuse it at the annotation top and in `_annotated_base`). Aliasing the whole annotation already worked; this makes the aliased-marker-in-metadata spelling work on par with tuple unpacking. Also: docs (decorator guide gains a spellings/sharing/composition section and the new spelling; bilingual CHANGELOG page wired into mkdocs nav), bump to 1.0.1.
1 parent 1bb4115 commit 8ebce91

10 files changed

Lines changed: 196 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ src-layout, package `src/fastapi_typed_errors/`:
5656
- Normalization: `-> Annotated[Response-subclass | None, Raises[...]]``response_model=None` (FastAPI's `lenient_issubclass` cannot see through `Annotated`; without this the route dies with `FastAPIError` / grows a spurious `null` schema). Bare stream returns (`AsyncIterator` + Raises) are unsupported — documented limitation.
5757
- Merge semantics: derived entries lose to explicit per-route `responses` wholesale per status; router-level `responses` are merged by FastAPI itself and lose to per-route ones.
5858
- Endpoint unwrapping for annotation reading only: `functools.partial` chain → `inspect.unwrap` → callable-instance `type(obj).__call__`; the original endpoint object is always what gets registered.
59-
- `_find_raises` is recursive: unwraps PEP 695 `TypeAliasType`, descends into `Annotated` bases and union arms — a declared marker must never be dropped silently (adversarial review caught all three as silent-drop bugs). `_annotated_base` applies the same unwrapping for the Response/None normalization.
59+
- `_find_raises` is recursive: unwraps PEP 695 `TypeAliasType` (both the whole annotation AND each `Annotated` metadata element, via the shared `_unwrap_type_alias` helper — a marker shared through `type Common = Raises[...]` sits in metadata as a `TypeAliasType`), descends into `Annotated` bases and union arms — a declared marker must never be dropped silently (adversarial review caught the base/union/metadata cases as silent-drop bugs). `_annotated_base` applies the same unwrapping for the Response/None normalization.
6060
- 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.
6161
- Follow-up idea (not implemented): PEP 692 `Unpack[TypedDict]` typing for registration kwargs.
6262

docs/en/changelog.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
title: Changelog
3+
---
4+
5+
# Changelog
6+
7+
All notable changes to this project are documented here.
8+
9+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
10+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11+
12+
## [1.0.1] — 2026-07-25
13+
14+
### Fixed
15+
16+
- A `Raises` marker shared through a PEP 695 `type` alias and placed as a metadata
17+
element of `Annotated` (`Annotated[Item, CommonRaises, Raises[...]]`) is no longer
18+
dropped silently — the alias is now unwrapped in metadata position too, on par with
19+
tuple unpacking. Aliasing the whole annotation already worked.
20+
21+
## [1.0.0] — 2026-07-24
22+
23+
### Added
24+
25+
- First stable release. Three independent layers:
26+
- **core**`BaseError`, the `BaseErrorMeta` metaclass, `ErrorResponse`,
27+
`error_models()`, `handle_base_error`.
28+
- **decorator**`with_errors(router)` with the `Raises[...]` marker, plus
29+
`auto=True` to fill `responses` from a static walk of the endpoint and its
30+
dependency tree.
31+
- **analysis**`check_raises()` CI checker comparing declared vs. actually
32+
raised errors, and a `typer` CLI (`cli` extra).

docs/en/guide/decorator.md

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,17 @@ def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenErr
5151

5252
The success (`200`) schema stays clean — pydantic ignores the marker. Several errors on one status become a discriminated `oneOf` union.
5353

54-
### Spellings and shared tuples
54+
### Spellings, sharing & composition
5555

56-
Shared error sets (auth errors, say) are convenient to factor into a tuple and unpack:
56+
There is more than one way to declare errors, and they compose freely. Pick whichever reads best at the call site.
57+
58+
**Inline list** — a one-off set of errors, right on the route:
59+
60+
```python
61+
def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]: ...
62+
```
63+
64+
**Shared tuple** — factor a recurring set into a tuple and unpack it, by subscription or via the constructor:
5765

5866
```python
5967
from typing import Final
@@ -68,20 +76,46 @@ def b() -> Annotated[Item, Raises(*TOKEN_ERRORS)]: ...
6876

6977
The constructor form `Raises(*TOKEN_ERRORS)` is the statically clean fallback for type checkers.
7078

79+
**Named marker alias** — give a domain error set a name with a PEP 695 `type` alias and reuse it across many routes (and even many routers):
80+
81+
```python
82+
type AuthErrors = Raises[RequiredTokenError, InvalidTokenError, WrongTokenTypeError]
83+
84+
85+
def me() -> Annotated[User, AuthErrors]: ...
86+
def stats() -> Annotated[Stats, AuthErrors, Raises[RateLimitedError]]: ... # shared set + a local one
87+
```
88+
89+
**Composition** — put several markers side by side in one `Annotated`: a shared set plus route-specific errors. They are **concatenated and deduplicated**, so an overlap between markers is harmless:
90+
91+
```python
92+
def transfer() -> Annotated[Account, AuthErrors, OwnershipErrors, Raises[ConflictError]]: ...
93+
```
94+
95+
| When | Use |
96+
| --- | --- |
97+
| One-off set on a single route | inline `Raises[A, B]` |
98+
| Recurring set, unpacked ad-hoc | `Raises[*TUPLE]` / `Raises(*TUPLE)` (statically clean) |
99+
| Named domain set reused widely | `type AuthErrors = Raises[...]` |
100+
| Shared set + route-local additions | compose markers: `Annotated[T, AuthErrors, Raises[C]]` |
101+
71102
!!! note "Validation is at import time"
72103

73104
`Raises[...]` validates each member as soon as it is evaluated (i.e. at module import): it must be a `BaseError` subclass with a declared `error_code` and `http_status`, and the list is non-empty. Otherwise — a `TypeError` naming the offender.
74105

75106
### What Raises finds in the annotation
76107

77-
The marker is found through PEP 695 `type` aliases, nested `Annotated` bases and union arms — a declared marker is **never dropped silently**:
108+
The marker is found through PEP 695 `type` aliases (wrapping the whole annotation **or** the marker itself in metadata), nested `Annotated` bases and union arms — a declared marker is **never dropped silently**:
78109

79110
```python
80111
type ItemNF = Annotated[Item, Raises[NotFoundError]]
112+
type CommonRaises = Raises[NotFoundError, ForbiddenError]
81113

82114

83-
def a() -> ItemNF: ... # alias
115+
def a() -> ItemNF: ... # alias of the whole annotation
84116
def b() -> Annotated[Item, Raises[NotFoundError]] | None: ... # union wrapper
117+
def c() -> Annotated[Item, CommonRaises, Raises[ConflictError]]: ... # aliased marker in metadata
118+
def d() -> Annotated[ItemNF, Raises[ConflictError]]: ... # aliased annotation nested in another
85119
```
86120

87121
## Merge semantics

docs/ru/changelog.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
title: История изменений
3+
---
4+
5+
# История изменений
6+
7+
Здесь задокументированы все значимые изменения проекта.
8+
9+
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
10+
проект придерживается [семантического версионирования](https://semver.org/lang/ru/spec/v2.0.0.html).
11+
12+
## [1.0.1] — 2026-07-25
13+
14+
### Исправлено
15+
16+
- Маркер `Raises`, переиспользуемый через PEP 695 `type`-алиас и стоящий элементом
17+
метаданных `Annotated` (`Annotated[Item, CommonRaises, Raises[...]]`), больше не
18+
теряется молча — теперь алиас разматывается и в позиции метаданных, наравне с
19+
распаковкой кортежа. Алиас всей аннотации работал и раньше.
20+
21+
## [1.0.0] — 2026-07-24
22+
23+
### Добавлено
24+
25+
- Первый стабильный релиз. Три независимых слоя:
26+
- **core**`BaseError`, метакласс `BaseErrorMeta`, `ErrorResponse`,
27+
`error_models()`, `handle_base_error`.
28+
- **decorator**`with_errors(router)` с маркером `Raises[...]`, плюс
29+
`auto=True` для заполнения `responses` статическим обходом эндпоинта и его
30+
дерева зависимостей.
31+
- **analysis** — CI-чекер `check_raises()`, сравнивающий объявленные и реально
32+
поднимаемые ошибки, и CLI на `typer` (экстра `cli`).

docs/ru/guide/decorator.md

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,17 @@ def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenErr
5151

5252
Успешная (`200`) схема остаётся чистой — pydantic игнорирует маркер. Несколько ошибок на одном статусе становятся discriminated `oneOf`-union.
5353

54-
### Формы записи и общие кортежи
54+
### Формы записи, переиспользование и композиция
5555

56-
Общие наборы ошибок (например, ошибки авторизации) удобно выносить в кортеж и распаковывать:
56+
Объявлять ошибки можно несколькими способами, и они свободно комбинируются. Выбирайте тот, что читается лучше на месте вызова.
57+
58+
**Инлайн-список** — разовый набор ошибок прямо на роуте:
59+
60+
```python
61+
def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]: ...
62+
```
63+
64+
**Общий кортеж** — повторяющийся набор выносим в кортеж и распаковываем, субскрипцией или через конструктор:
5765

5866
```python
5967
from typing import Final
@@ -68,20 +76,46 @@ def b() -> Annotated[Item, Raises(*TOKEN_ERRORS)]: ...
6876

6977
Конструкторная форма `Raises(*TOKEN_ERRORS)` — статически «чистая» запасная для тайпчекеров.
7078

79+
**Именованный алиас-маркер** — дайте доменному набору ошибок имя через PEP 695 `type`-алиас и переиспользуйте его на многих роутах (и даже на разных роутерах):
80+
81+
```python
82+
type AuthErrors = Raises[RequiredTokenError, InvalidTokenError, WrongTokenTypeError]
83+
84+
85+
def me() -> Annotated[User, AuthErrors]: ...
86+
def stats() -> Annotated[Stats, AuthErrors, Raises[RateLimitedError]]: ... # общий набор + локальная
87+
```
88+
89+
**Композиция** — поставьте несколько маркеров рядом в одном `Annotated`: общий набор плюс специфичные для роута ошибки. Они **конкатенируются и дедуплицируются**, так что пересечение маркеров безвредно:
90+
91+
```python
92+
def transfer() -> Annotated[Account, AuthErrors, OwnershipErrors, Raises[ConflictError]]: ...
93+
```
94+
95+
| Когда | Что использовать |
96+
| --- | --- |
97+
| Разовый набор на одном роуте | инлайн `Raises[A, B]` |
98+
| Повторяющийся набор, распаковка ad-hoc | `Raises[*TUPLE]` / `Raises(*TUPLE)` (статически «чистая») |
99+
| Именованный доменный набор для широкого переиспользования | `type AuthErrors = Raises[...]` |
100+
| Общий набор + локальные добавки на роуте | композиция маркеров: `Annotated[T, AuthErrors, Raises[C]]` |
101+
71102
!!! note "Валидация — на этапе импорта"
72103

73104
`Raises[...]` проверяет каждый член сразу при вычислении (то есть при импорте модуля): это должен быть подкласс `BaseError` с объявленными `error_code` и `http_status`, список непуст. Иначе — `TypeError` с именем нарушителя.
74105

75106
### Что видит `Raises` в аннотации
76107

77-
Маркер находится сквозь PEP 695 `type`-алиасы, вложенные `Annotated`-базы и члены union — задекларированный маркер **никогда не теряется молча**:
108+
Маркер находится сквозь PEP 695 `type`-алиасы (оборачивающие как всю аннотацию, так и **сам маркер** в позиции метаданных), вложенные `Annotated`-базы и члены union — задекларированный маркер **никогда не теряется молча**:
78109

79110
```python
80111
type ItemNF = Annotated[Item, Raises[NotFoundError]]
112+
type CommonRaises = Raises[NotFoundError, ForbiddenError]
81113

82114

83-
def a() -> ItemNF: ... # алиас
115+
def a() -> ItemNF: ... # алиас всей аннотации
84116
def b() -> Annotated[Item, Raises[NotFoundError]] | None: ... # union-обёртка
117+
def c() -> Annotated[Item, CommonRaises, Raises[ConflictError]]: ... # алиас-маркер в метаданных
118+
def d() -> Annotated[ItemNF, Raises[ConflictError]]: ... # алиас-аннотация, вложенная в другую
85119
```
86120

87121
## Семантика слияния

mkdocs.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ plugins:
8585
Overview: Обзор
8686
Guide: Руководство
8787
API Reference: Справочник API
88+
Changelog: История изменений
8889
- mkdocstrings:
8990
default_handler: python
9091
handlers:
@@ -126,3 +127,5 @@ nav:
126127
- reference/core.md
127128
- reference/decorator.md
128129
- reference/analysis.md
130+
- Changelog:
131+
- changelog.md

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-typed-errors"
3-
version = "1.0.0"
3+
version = "1.0.1"
44
description = "Typed error responses for FastAPI: Literal error codes, discriminated unions in OpenAPI, single source of truth"
55
readme = "README.md"
66
authors = [

src/fastapi_typed_errors/decorator/wrapper.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,20 @@ def _return_annotation(fn: Callable[..., Any], path: object) -> object:
152152
return hints.get("return")
153153

154154

155+
def _unwrap_type_alias(annotation: object) -> object:
156+
"""Peel PEP 695 ``type`` aliases off an annotation, following the chain.
157+
158+
Args:
159+
annotation: Any annotation object, possibly a ``TypeAliasType``.
160+
161+
Returns:
162+
object: The underlying value with all ``type`` aliases resolved.
163+
"""
164+
while isinstance(annotation, TypeAliasType):
165+
annotation = annotation.__value__
166+
return annotation
167+
168+
155169
def _find_raises(annotation: object) -> tuple[Raises, ...]:
156170
"""Extract ``Raises`` markers from a return annotation, recursively.
157171
@@ -170,8 +184,7 @@ def _find_raises(annotation: object) -> tuple[Raises, ...]:
170184
TypeError: On a bare ``Raises`` instance used as the whole annotation,
171185
or an unparametrized ``Raises`` class inside ``Annotated``.
172186
"""
173-
while isinstance(annotation, TypeAliasType):
174-
annotation = annotation.__value__
187+
annotation = _unwrap_type_alias(annotation)
175188
if annotation is None:
176189
return ()
177190
if isinstance(annotation, Raises):
@@ -180,6 +193,10 @@ def _find_raises(annotation: object) -> tuple[Raises, ...]:
180193
origin = get_origin(annotation)
181194
if origin is Annotated:
182195
base, *metadata = get_args(annotation)
196+
# Unwrap each metadata element too: a marker shared via a PEP 695 `type`
197+
# alias (`type Common = Raises[...]`) sits here as a TypeAliasType, and
198+
# must be seen through — otherwise a declared marker is dropped silently.
199+
metadata = [_unwrap_type_alias(meta) for meta in metadata]
183200
if any(meta is Raises for meta in metadata):
184201
msg = "Raises must be parametrized: Annotated[Model, Raises[Error, ...]]"
185202
raise TypeError(msg)
@@ -201,8 +218,7 @@ def _annotated_base(annotation: object) -> object:
201218
``Response`` / ``None`` normalization check).
202219
"""
203220
while True:
204-
while isinstance(annotation, TypeAliasType):
205-
annotation = annotation.__value__
221+
annotation = _unwrap_type_alias(annotation)
206222
if get_origin(annotation) is not Annotated:
207223
return annotation
208224
annotation = get_args(annotation)[0]

tests/decorator/test_wrapper.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class Item(BaseModel):
6868

6969
type AliasedItem = Annotated[Item, Raises[NotFoundError]]
7070
type RawStream = StreamingResponse
71+
type CommonRaises = Raises[NotFoundError, ForbiddenError]
7172

7273

7374
def conflicting(q: int = 1) -> Annotated[Item, Raises[ConflictError]]:
@@ -276,6 +277,35 @@ def endpoint() -> Annotated[AliasedItem, Raises[ForbiddenError]]:
276277
assert "403" in responses
277278

278279

280+
@requires_modern_fastapi
281+
def test_aliased_marker_in_metadata_detected(router: APIRouter) -> None:
282+
"""A ``Raises`` marker shared via a ``type`` alias is not dropped in metadata."""
283+
284+
@router.get("/shared")
285+
def endpoint() -> Annotated[Item, CommonRaises, Raises[ConflictError]]:
286+
raise NotImplementedError
287+
288+
responses = _responses(router, "/shared")
289+
290+
assert "404" in responses
291+
assert "403" in responses
292+
assert "409" in responses
293+
294+
295+
@requires_modern_fastapi
296+
def test_aliased_marker_sole_metadata_detected(router: APIRouter) -> None:
297+
"""An aliased marker as the only metadata element is unwrapped and read."""
298+
299+
@router.get("/soleshared")
300+
def endpoint() -> Annotated[Item, CommonRaises]:
301+
raise NotImplementedError
302+
303+
responses = _responses(router, "/soleshared")
304+
305+
assert "404" in responses
306+
assert "403" in responses
307+
308+
279309
def test_alias_base_is_normalized(router: APIRouter) -> None:
280310
"""An aliased ``Response`` subclass still triggers normalization."""
281311

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)