Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ src-layout, package `src/fastapi_typed_errors/`:
- 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.
- 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.
- 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.
- `_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.
- `_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.
- 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.
- Follow-up idea (not implemented): PEP 692 `Unpack[TypedDict]` typing for registration kwargs.

Expand Down
32 changes: 32 additions & 0 deletions docs/en/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: Changelog
---

# Changelog

All notable changes to this project are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.1] — 2026-07-25

### Fixed

- A `Raises` marker shared through a PEP 695 `type` alias and placed as a metadata
element of `Annotated` (`Annotated[Item, CommonRaises, Raises[...]]`) is no longer
dropped silently — the alias is now unwrapped in metadata position too, on par with
tuple unpacking. Aliasing the whole annotation already worked.

## [1.0.0] — 2026-07-24

### Added

- First stable release. Three independent layers:
- **core** — `BaseError`, the `BaseErrorMeta` metaclass, `ErrorResponse`,
`error_models()`, `handle_base_error`.
- **decorator** — `with_errors(router)` with the `Raises[...]` marker, plus
`auto=True` to fill `responses` from a static walk of the endpoint and its
dependency tree.
- **analysis** — `check_raises()` CI checker comparing declared vs. actually
raised errors, and a `typer` CLI (`cli` extra).
42 changes: 38 additions & 4 deletions docs/en/guide/decorator.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,17 @@ def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenErr

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

### Spellings and shared tuples
### Spellings, sharing & composition

Shared error sets (auth errors, say) are convenient to factor into a tuple and unpack:
There is more than one way to declare errors, and they compose freely. Pick whichever reads best at the call site.

**Inline list** — a one-off set of errors, right on the route:

```python
def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]: ...
```

**Shared tuple** — factor a recurring set into a tuple and unpack it, by subscription or via the constructor:

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

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

**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):

```python
type AuthErrors = Raises[RequiredTokenError, InvalidTokenError, WrongTokenTypeError]


def me() -> Annotated[User, AuthErrors]: ...
def stats() -> Annotated[Stats, AuthErrors, Raises[RateLimitedError]]: ... # shared set + a local one
```

**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:

```python
def transfer() -> Annotated[Account, AuthErrors, OwnershipErrors, Raises[ConflictError]]: ...
```

| When | Use |
| --- | --- |
| One-off set on a single route | inline `Raises[A, B]` |
| Recurring set, unpacked ad-hoc | `Raises[*TUPLE]` / `Raises(*TUPLE)` (statically clean) |
| Named domain set reused widely | `type AuthErrors = Raises[...]` |
| Shared set + route-local additions | compose markers: `Annotated[T, AuthErrors, Raises[C]]` |

!!! note "Validation is at import time"

`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.

### What Raises finds in the annotation

The marker is found through PEP 695 `type` aliases, nested `Annotated` bases and union arms — a declared marker is **never dropped silently**:
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**:

```python
type ItemNF = Annotated[Item, Raises[NotFoundError]]
type CommonRaises = Raises[NotFoundError, ForbiddenError]


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

## Merge semantics
Expand Down
32 changes: 32 additions & 0 deletions docs/ru/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: История изменений
---

# История изменений

Здесь задокументированы все значимые изменения проекта.

Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/spec/v2.0.0.html).

## [1.0.1] — 2026-07-25

### Исправлено

- Маркер `Raises`, переиспользуемый через PEP 695 `type`-алиас и стоящий элементом
метаданных `Annotated` (`Annotated[Item, CommonRaises, Raises[...]]`), больше не
теряется молча — теперь алиас разматывается и в позиции метаданных, наравне с
распаковкой кортежа. Алиас всей аннотации работал и раньше.

## [1.0.0] — 2026-07-24

### Добавлено

- Первый стабильный релиз. Три независимых слоя:
- **core** — `BaseError`, метакласс `BaseErrorMeta`, `ErrorResponse`,
`error_models()`, `handle_base_error`.
- **decorator** — `with_errors(router)` с маркером `Raises[...]`, плюс
`auto=True` для заполнения `responses` статическим обходом эндпоинта и его
дерева зависимостей.
- **analysis** — CI-чекер `check_raises()`, сравнивающий объявленные и реально
поднимаемые ошибки, и CLI на `typer` (экстра `cli`).
42 changes: 38 additions & 4 deletions docs/ru/guide/decorator.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,17 @@ def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenErr

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

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

Общие наборы ошибок (например, ошибки авторизации) удобно выносить в кортеж и распаковывать:
Объявлять ошибки можно несколькими способами, и они свободно комбинируются. Выбирайте тот, что читается лучше на месте вызова.

**Инлайн-список** — разовый набор ошибок прямо на роуте:

```python
def get_item(item_id: int) -> Annotated[Item, Raises[NotFoundError, ForbiddenError]]: ...
```

**Общий кортеж** — повторяющийся набор выносим в кортеж и распаковываем, субскрипцией или через конструктор:

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

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

**Именованный алиас-маркер** — дайте доменному набору ошибок имя через PEP 695 `type`-алиас и переиспользуйте его на многих роутах (и даже на разных роутерах):

```python
type AuthErrors = Raises[RequiredTokenError, InvalidTokenError, WrongTokenTypeError]


def me() -> Annotated[User, AuthErrors]: ...
def stats() -> Annotated[Stats, AuthErrors, Raises[RateLimitedError]]: ... # общий набор + локальная
```

**Композиция** — поставьте несколько маркеров рядом в одном `Annotated`: общий набор плюс специфичные для роута ошибки. Они **конкатенируются и дедуплицируются**, так что пересечение маркеров безвредно:

```python
def transfer() -> Annotated[Account, AuthErrors, OwnershipErrors, Raises[ConflictError]]: ...
```

| Когда | Что использовать |
| --- | --- |
| Разовый набор на одном роуте | инлайн `Raises[A, B]` |
| Повторяющийся набор, распаковка ad-hoc | `Raises[*TUPLE]` / `Raises(*TUPLE)` (статически «чистая») |
| Именованный доменный набор для широкого переиспользования | `type AuthErrors = Raises[...]` |
| Общий набор + локальные добавки на роуте | композиция маркеров: `Annotated[T, AuthErrors, Raises[C]]` |

!!! note "Валидация — на этапе импорта"

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

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

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

```python
type ItemNF = Annotated[Item, Raises[NotFoundError]]
type CommonRaises = Raises[NotFoundError, ForbiddenError]


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

## Семантика слияния
Expand Down
3 changes: 3 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ plugins:
Overview: Обзор
Guide: Руководство
API Reference: Справочник API
Changelog: История изменений
- mkdocstrings:
default_handler: python
handlers:
Expand Down Expand Up @@ -126,3 +127,5 @@ nav:
- reference/core.md
- reference/decorator.md
- reference/analysis.md
- Changelog:
- changelog.md
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "fastapi-typed-errors"
version = "1.0.0"
version = "1.0.1"
description = "Typed error responses for FastAPI: Literal error codes, discriminated unions in OpenAPI, single source of truth"
readme = "README.md"
authors = [
Expand Down
24 changes: 20 additions & 4 deletions src/fastapi_typed_errors/decorator/wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,20 @@ def _return_annotation(fn: Callable[..., Any], path: object) -> object:
return hints.get("return")


def _unwrap_type_alias(annotation: object) -> object:
"""Peel PEP 695 ``type`` aliases off an annotation, following the chain.

Args:
annotation: Any annotation object, possibly a ``TypeAliasType``.

Returns:
object: The underlying value with all ``type`` aliases resolved.
"""
while isinstance(annotation, TypeAliasType):
annotation = annotation.__value__
return annotation


def _find_raises(annotation: object) -> tuple[Raises, ...]:
"""Extract ``Raises`` markers from a return annotation, recursively.

Expand All @@ -170,8 +184,7 @@ def _find_raises(annotation: object) -> tuple[Raises, ...]:
TypeError: On a bare ``Raises`` instance used as the whole annotation,
or an unparametrized ``Raises`` class inside ``Annotated``.
"""
while isinstance(annotation, TypeAliasType):
annotation = annotation.__value__
annotation = _unwrap_type_alias(annotation)
if annotation is None:
return ()
if isinstance(annotation, Raises):
Expand All @@ -180,6 +193,10 @@ def _find_raises(annotation: object) -> tuple[Raises, ...]:
origin = get_origin(annotation)
if origin is Annotated:
base, *metadata = get_args(annotation)
# Unwrap each metadata element too: a marker shared via a PEP 695 `type`
# alias (`type Common = Raises[...]`) sits here as a TypeAliasType, and
# must be seen through — otherwise a declared marker is dropped silently.
metadata = [_unwrap_type_alias(meta) for meta in metadata]
if any(meta is Raises for meta in metadata):
msg = "Raises must be parametrized: Annotated[Model, Raises[Error, ...]]"
raise TypeError(msg)
Expand All @@ -201,8 +218,7 @@ def _annotated_base(annotation: object) -> object:
``Response`` / ``None`` normalization check).
"""
while True:
while isinstance(annotation, TypeAliasType):
annotation = annotation.__value__
annotation = _unwrap_type_alias(annotation)
if get_origin(annotation) is not Annotated:
return annotation
annotation = get_args(annotation)[0]
Expand Down
30 changes: 30 additions & 0 deletions tests/decorator/test_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class Item(BaseModel):

type AliasedItem = Annotated[Item, Raises[NotFoundError]]
type RawStream = StreamingResponse
type CommonRaises = Raises[NotFoundError, ForbiddenError]


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


@requires_modern_fastapi
def test_aliased_marker_in_metadata_detected(router: APIRouter) -> None:
"""A ``Raises`` marker shared via a ``type`` alias is not dropped in metadata."""

@router.get("/shared")
def endpoint() -> Annotated[Item, CommonRaises, Raises[ConflictError]]:
raise NotImplementedError

responses = _responses(router, "/shared")

assert "404" in responses
assert "403" in responses
assert "409" in responses


@requires_modern_fastapi
def test_aliased_marker_sole_metadata_detected(router: APIRouter) -> None:
"""An aliased marker as the only metadata element is unwrapped and read."""

@router.get("/soleshared")
def endpoint() -> Annotated[Item, CommonRaises]:
raise NotImplementedError

responses = _responses(router, "/soleshared")

assert "404" in responses
assert "403" in responses


def test_alias_base_is_normalized(router: APIRouter) -> None:
"""An aliased ``Response`` subclass still triggers normalization."""

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading