mirror of https://github.com/tiangolo/fastapi.git
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:
parent
11614be902
commit
e00b071435
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue