Browse Source

Fix Annotated forward ref resolution with from __future__ import annotations

When using rom __future__ import annotations, annotations are stored as
strings. If Annotated[SomeClass, Depends()] references a class defined
after the route decorator, the forward reference could not be resolved at
decoration time.

FastAPI's evaluate_forwardref() does not raise on unresolvable references
- it returns the raw ForwardRef object. The existing code only handled the
string-to-ForwardRef conversion but did not check whether resolution
actually succeeded, allowing an unresolvable ForwardRef to leak into the
type annotation. This caused Pydantic to fail with
'class-not-fully-defined' errors and the Depends metadata to be lost.

The fix adds a fallback: when evaluate_forwardref() returns a ForwardRef
(rather than the resolved type) and the annotation string is Annotated-
shaped, a lenient resolution evaluates the annotation with a namespace
that maps undefined names to Any. This preserves the Annotated structure
so FastAPI can extract the Depends metadata.
pull/15519/head
Codebuff Contributor 3 weeks ago
parent
commit
becafff6b5
  1. 33
      fastapi/dependencies/utils.py
  2. 67
      tests/test_annotated_forward_ref.py

33
fastapi/dependencies/utils.py

@ -246,11 +246,42 @@ def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
if isinstance(annotation, str):
annotation = ForwardRef(annotation)
annotation = evaluate_forwardref(annotation, globalns, globalns)
if annotation is type(None):
if isinstance(annotation, ForwardRef):
if "Annotated[" in annotation.__forward_arg__:
annotation = _resolve_forward_ref_lenient(
annotation, globalns
)
elif annotation is type(None):
return None
return annotation
def _resolve_forward_ref_lenient(
fwd_ref: ForwardRef,
globalns: dict[str, Any],
) -> Any:
class _LenientNamespace(dict):
def __missing__(self, key: str) -> Any:
return Any
localns = _LenientNamespace(globalns)
result = fwd_ref._evaluate(globalns, localns, set())
return result
def _resolve_forward_ref_lenient(
fwd_ref: ForwardRef,
globalns: dict[str, Any],
) -> Any:
class _LenientNamespace(dict):
def __missing__(self, key: str) -> Any:
return Any
localns = _LenientNamespace(globalns)
result = fwd_ref._evaluate(globalns, localns, set())
return result
def get_typed_return_annotation(call: Callable[..., Any]) -> Any:
signature = _get_signature(call)
unwrapped = inspect.unwrap(call)

67
tests/test_annotated_forward_ref.py

@ -0,0 +1,67 @@
from __future__ import annotations
from typing import Annotated
from inline_snapshot import snapshot
from fastapi import Depends, FastAPI
# Simulate the real-world bug: Potato is defined AFTER the route decorator
# under `from __future__ import annotations`.
app = FastAPI()
def get_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}
from dataclasses import dataclass
@dataclass
class Potato:
color: str
size: int
def test_forward_ref_annotated_depends():
from fastapi.testclient import TestClient
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200, resp.text
assert resp.json() == {"color": "red", "size": 10}
def test_forward_ref_annotated_depends_openapi():
from fastapi.testclient import TestClient
client = TestClient(app)
resp = client.get("/openapi.json")
assert resp.status_code == 200, resp.text
assert resp.json() == snapshot(
{
"openapi": "3.1.0",
"info": {"title": "FastAPI", "version": "0.1.0"},
"paths": {
"/": {
"get": {
"summary": "Read Root",
"operationId": "read_root__get",
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
}
},
}
}
},
}
)
Loading…
Cancel
Save