From e00b0714359d395099403bbd4c79e8f83a212480 Mon Sep 17 00:00:00 2001 From: "ahsan.sheraz" Date: Wed, 11 Mar 2026 16:01:13 +0100 Subject: [PATCH] Fix Annotated with ForwardRef when using `from __future__ import annotations` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `from __future__ import annotations` is active, annotations become strings. If a type referenced inside `Annotated[T, Depends(...)]` is defined after the route decorator, `T` is not yet available at decoration time and the full `Annotated` string cannot be resolved. FastAPI then loses the `Depends` metadata and incorrectly treats the parameter as a query parameter. The fix introduces a lenient evaluation strategy: when strict evaluation of the annotation string fails, a `_LenientDict` namespace is used where undefined names resolve to `Any`. This preserves the `Annotated` structure and allows FastAPI to extract `Depends` metadata. The lenient result is only accepted when it contains `Depends` metadata — for other metadata types (Query, Body, etc.) the actual type is needed, so the existing fallback behaviour is kept. Fixes #13056 Made-with: Cursor --- fastapi/dependencies/utils.py | 50 +++++++++ tests/test_annotated_forward_ref.py | 160 ++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 tests/test_annotated_forward_ref.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 8fcf1a5b3c..d83bb05361 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -240,8 +240,58 @@ def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: return typed_signature +class _LenientDict(dict): + """Dict subclass where missing keys resolve to Any. + + Used as an eval namespace so that undefined forward references (e.g. a + class defined later in the module) evaluate to ``Any`` instead of raising + ``NameError``. This preserves the ``Annotated`` structure and lets + FastAPI extract dependency metadata even when the concrete type is not yet + available. + """ + + def __missing__(self, key: str) -> type: + return Any + + +def _try_resolve_annotated( + annotation_str: str, globalns: dict[str, Any] +) -> Any | None: + """Resolve an ``Annotated`` string annotation, tolerating missing names. + + Returns the evaluated ``Annotated`` type when the metadata contains a + ``Depends`` instance, or ``None`` so the caller falls through to the + default resolution path. + """ + # 1) Strict evaluation – works when every name is already defined. + try: + ref = ForwardRef(annotation_str) + evaluated = evaluate_forwardref(ref, globalns, globalns) + if get_origin(evaluated) is Annotated: + return evaluated + except Exception: + pass + + # 2) Lenient evaluation – undefined names become ``Any``. + try: + lenient_ns = _LenientDict(globalns) + evaluated = eval(annotation_str, lenient_ns) # noqa: S307 + if get_origin(evaluated) is Annotated: + args = get_args(evaluated) + if any(isinstance(a, params.Depends) for a in args[1:]): + return evaluated + except Exception: + pass + + return None + + def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: if isinstance(annotation, str): + if annotation.startswith("Annotated["): + result = _try_resolve_annotated(annotation, globalns) + if result is not None: + return result annotation = ForwardRef(annotation) annotation = evaluate_forwardref(annotation, globalns, globalns) if annotation is type(None): diff --git a/tests/test_annotated_forward_ref.py b/tests/test_annotated_forward_ref.py new file mode 100644 index 0000000000..80cc2d9949 --- /dev/null +++ b/tests/test_annotated_forward_ref.py @@ -0,0 +1,160 @@ +"""Regression tests for issue #13056. + +``Annotated[SomeClass, Depends()]`` must work even when *SomeClass* is defined +after the route handler and ``from __future__ import annotations`` is active. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated, Any, get_args, get_origin + +from fastapi import Depends, FastAPI, Query, params +from fastapi.dependencies.utils import ( + _LenientDict, + _try_resolve_annotated, + get_typed_annotation, +) +from fastapi.testclient import TestClient + +app = FastAPI() + + +def get_potato() -> Potato: + return Potato(color="red", size=10) + + +@app.get("/") +async def read_root(potato: Annotated[Potato, Depends(get_potato)]): + return {"color": potato.color, "size": potato.size} + + +@dataclass +class Potato: + color: str + size: int + + +client = TestClient(app) + + +# -- Integration tests ------------------------------------------------------- + + +def test_annotated_forward_ref(): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"color": "red", "size": 10} + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200 + schema = response.json() + root_path = schema["paths"]["/"]["get"] + params = root_path.get("parameters", []) + potato_params = [p for p in params if "potato" in p.get("name", "").lower()] + assert potato_params == [], "potato must not appear as a query parameter" + + +# -- _LenientDict ----------------------------------------------------------- + + +def test_lenient_dict_returns_any_for_missing_keys(): + d = _LenientDict({"existing": int}) + assert d["existing"] is int + assert d["missing"] is Any + + +# -- _try_resolve_annotated: strict path ------------------------------------ + + +def test_try_resolve_strict_success(): + """Fully resolvable annotation returns immediately via strict eval.""" + + def dep() -> str: + return "ok" + + ns = {"Annotated": Annotated, "str": str, "Depends": Depends, "dep": dep} + result = _try_resolve_annotated("Annotated[str, Depends(dep)]", ns) + assert result is not None + assert get_origin(result) is Annotated + args = get_args(result) + assert args[0] is str + + +# -- _try_resolve_annotated: lenient path with Depends ---------------------- + + +def test_try_resolve_lenient_with_depends(): + """Unresolvable type + Depends → Annotated[Any, Depends(...)].""" + + def dep() -> str: + return "ok" + + ns = {"Annotated": Annotated, "Depends": Depends, "dep": dep} + result = _try_resolve_annotated("Annotated[MissingClass, Depends(dep)]", ns) + assert result is not None + assert get_origin(result) is Annotated + args = get_args(result) + assert args[0] is Any + assert isinstance(args[1], params.Depends) + + +# -- _try_resolve_annotated: lenient path without Depends ------------------- + + +def test_try_resolve_lenient_non_depends_returns_none(): + """Unresolvable type + Query → None (actual type needed for Query).""" + ns = {"Annotated": Annotated, "Query": Query} + result = _try_resolve_annotated("Annotated[MissingClass, Query()]", ns) + assert result is None + + +# -- _try_resolve_annotated: both paths fail -------------------------------- + + +def test_try_resolve_completely_invalid(): + """Completely invalid expression returns None.""" + result = _try_resolve_annotated("Annotated[", {}) + assert result is None + + +def test_try_resolve_strict_exception_lenient_exception(): + """Both strict and lenient raise → returns None.""" + result = _try_resolve_annotated("Annotated[{bad syntax", {}) + assert result is None + + +# -- get_typed_annotation passthrough tests ---------------------------------- + + +def test_get_typed_annotation_non_annotated_string(): + """Plain string annotation falls through to default ForwardRef path.""" + ns: dict[str, Any] = {"str": str} + result = get_typed_annotation("str", ns) + assert result is str + + +def test_get_typed_annotation_non_string(): + """Non-string annotation is returned as-is.""" + result = get_typed_annotation(int, {}) + assert result is int + + +def test_get_typed_annotation_annotated_with_result(): + """Annotated string that resolves via _try_resolve_annotated.""" + + def dep() -> str: + return "ok" + + ns = {"Annotated": Annotated, "str": str, "Depends": Depends, "dep": dep} + result = get_typed_annotation("Annotated[str, Depends(dep)]", ns) + assert get_origin(result) is Annotated + + +def test_get_typed_annotation_annotated_no_result_falls_through(): + """Annotated string with non-Depends metadata falls through to default.""" + ns: dict[str, Any] = {"Annotated": Annotated, "str": str, "Query": Query} + result = get_typed_annotation("Annotated[str, Query()]", ns) + assert get_origin(result) is Annotated