Fix Annotated with ForwardRef when using `from __future__ import annotations`

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
This commit is contained in:
ahsan.sheraz 2026-03-11 16:01:13 +01:00
parent 11614be902
commit e00b071435
2 changed files with 210 additions and 0 deletions

View File

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

View File

@ -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