diff --git a/CLAUDE.md b/CLAUDE.md index 541335c..487f8f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/en/changelog.md b/docs/en/changelog.md new file mode 100644 index 0000000..e835684 --- /dev/null +++ b/docs/en/changelog.md @@ -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). diff --git a/docs/en/guide/decorator.md b/docs/en/guide/decorator.md index f0cf70c..7b4bbec 100644 --- a/docs/en/guide/decorator.md +++ b/docs/en/guide/decorator.md @@ -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 @@ -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 diff --git a/docs/ru/changelog.md b/docs/ru/changelog.md new file mode 100644 index 0000000..09d27f5 --- /dev/null +++ b/docs/ru/changelog.md @@ -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`). diff --git a/docs/ru/guide/decorator.md b/docs/ru/guide/decorator.md index 82aaf03..1a4e24d 100644 --- a/docs/ru/guide/decorator.md +++ b/docs/ru/guide/decorator.md @@ -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 @@ -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]]: ... # алиас-аннотация, вложенная в другую ``` ## Семантика слияния diff --git a/mkdocs.yml b/mkdocs.yml index fae8b15..8b470d9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -85,6 +85,7 @@ plugins: Overview: Обзор Guide: Руководство API Reference: Справочник API + Changelog: История изменений - mkdocstrings: default_handler: python handlers: @@ -126,3 +127,5 @@ nav: - reference/core.md - reference/decorator.md - reference/analysis.md + - Changelog: + - changelog.md diff --git a/pyproject.toml b/pyproject.toml index fcf5cbb..303caa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/fastapi_typed_errors/decorator/wrapper.py b/src/fastapi_typed_errors/decorator/wrapper.py index 6abf99c..d7c18cb 100644 --- a/src/fastapi_typed_errors/decorator/wrapper.py +++ b/src/fastapi_typed_errors/decorator/wrapper.py @@ -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. @@ -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): @@ -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) @@ -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] diff --git a/tests/decorator/test_wrapper.py b/tests/decorator/test_wrapper.py index bdf5032..fe00f43 100644 --- a/tests/decorator/test_wrapper.py +++ b/tests/decorator/test_wrapper.py @@ -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]]: @@ -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.""" diff --git a/uv.lock b/uv.lock index df5a030..41c1269 100644 --- a/uv.lock +++ b/uv.lock @@ -233,7 +233,7 @@ wheels = [ [[package]] name = "fastapi-typed-errors" -version = "1.0.0" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "fastapi" },