Browse Source

Merge 1752749e70 into 460f8d2cc8

pull/15481/merge
Siddharth 12 hours ago
committed by GitHub
parent
commit
b7d3f9fdae
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 31
      fastapi/dependencies/utils.py
  2. 56
      tests/test_annotated_forwardref.py
  3. 73
      tests/test_dependencies_utils.py

31
fastapi/dependencies/utils.py

@ -243,11 +243,42 @@ def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
# Resolve bare string / ForwardRef annotations (e.g. produced by
# `from __future__ import annotations` when the annotation itself is a
# plain type name).
if isinstance(annotation, str):
annotation = ForwardRef(annotation)
annotation = evaluate_forwardref(annotation, globalns, globalns)
if annotation is type(None):
return None
# When `from __future__ import annotations` is active, the *base* type
# inside `Annotated[SomeType, ...]` may still be a string or ForwardRef
# even after the outer annotation has been evaluated. Handle that by
# resolving the base type and reconstructing the Annotated alias.
if get_origin(annotation) is Annotated:
args = get_args(annotation)
# args[0] is the base type; args[1:] are the metadata (Depends, etc.)
base_type = args[0]
metadata = args[1:]
# Resolve the base type if it is still a string or ForwardRef
if isinstance(base_type, str):
base_type = ForwardRef(base_type)
if isinstance(base_type, ForwardRef):
base_type = evaluate_forwardref(base_type, globalns, globalns)
# Recursively resolve in case of nested Annotated / ForwardRef
base_type = get_typed_annotation(base_type, globalns)
# Keep NoneType handling consistent with the bare-string path above
if base_type is type(None):
base_type = None
# Reconstruct Annotated correctly via __class_getitem__ so that
# Annotated[T, meta1, meta2] is produced, not Annotated[(T, meta1, meta2)].
annotation = Annotated.__class_getitem__((base_type, *metadata))
return annotation

56
tests/test_annotated_forwardref.py

@ -0,0 +1,56 @@
# IMPORTANT: This future import MUST be at the top of the module.
# It is what causes annotations to be stored as strings (ForwardRef),
# which is exactly the bug scenario described in issue #13056.
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
# ── App setup ─────────────────────────────────────────────────────────────────
app = FastAPI()
@dataclass
class Potato:
color: str
size: int
def get_potato() -> Potato:
return Potato(color="red", size=10)
# The annotation "Potato" is a string here because of `from __future__ import
# annotations`. Before the fix, FastAPI would fail to resolve it when it is
# wrapped inside Annotated[...].
@app.get("/")
def read_root(potato: Annotated[Potato, Depends(get_potato)]) -> dict:
return {"color": potato.color, "size": potato.size}
client = TestClient(app)
# ── Tests ──────────────────────────────────────────────────────────────────────
def test_annotated_forwardref_response() -> None:
"""Endpoint using Annotated[ForwardRef, Depends(...)] must return 200."""
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"color": "red", "size": 10}
def test_annotated_forwardref_openapi_schema() -> None:
"""OpenAPI schema must be generated without errors and must not leak ForwardRef."""
response = client.get("/openapi.json")
assert response.status_code == 200
schema_text = response.text
assert "ForwardRef" not in schema_text, (
"Unresolved ForwardRef leaked into the OpenAPI schema"
)

73
tests/test_dependencies_utils.py

@ -1,8 +1,73 @@
# NOTE: Do NOT add `from __future__ import annotations` here.
# That future import would turn the string subscripts in Annotated[...] bodies
# into deferred references and break isinstance() checks in this file.
from typing import Annotated, ForwardRef, get_args, get_origin
from fastapi import params
from fastapi.dependencies.utils import get_typed_annotation
# ---------------------------------------------------------------------------
# Existing coverage test
# ---------------------------------------------------------------------------
def test_get_typed_annotation():
# For coverage
annotation = "None"
typed_annotation = get_typed_annotation(annotation, globals())
def test_get_typed_annotation_none_string() -> None:
# A bare "None" string should resolve to Python's NoneType (returned as None)
typed_annotation = get_typed_annotation("None", globals())
assert typed_annotation is None
# ---------------------------------------------------------------------------
# Issue #13056 – ForwardRef inside Annotated
# ---------------------------------------------------------------------------
class _Potato:
"""Stand-in model used by the tests below."""
def _get_potato() -> "_Potato":
return _Potato()
def test_annotated_with_string_base_type() -> None:
"""Annotated["_Potato", Depends(...)] must resolve the base type."""
dep = params.Depends(_get_potato)
annotation = Annotated["_Potato", dep]
resolved = get_typed_annotation(annotation, globals())
assert get_origin(resolved) is Annotated
args = get_args(resolved)
assert args[0] is _Potato, f"Expected _Potato, got {args[0]}"
assert isinstance(args[1], params.Depends)
def test_annotated_with_forwardref_base_type() -> None:
"""Annotated[ForwardRef("_Potato"), Depends(...)] must also resolve."""
dep = params.Depends(_get_potato)
annotation = Annotated[ForwardRef("_Potato"), dep]
resolved = get_typed_annotation(annotation, globals())
assert get_origin(resolved) is Annotated
args = get_args(resolved)
assert args[0] is _Potato, f"Expected _Potato, got {args[0]}"
assert isinstance(args[1], params.Depends)
def test_annotated_with_already_resolved_type() -> None:
"""Annotated[_Potato, Depends(...)] (already resolved) must pass through unchanged."""
dep = params.Depends(_get_potato)
annotation = Annotated[_Potato, dep]
resolved = get_typed_annotation(annotation, globals())
assert get_origin(resolved) is Annotated
args = get_args(resolved)
assert args[0] is _Potato
assert isinstance(args[1], params.Depends)
def test_bare_string_annotation() -> None:
"""A bare string annotation (non-Annotated) must still resolve correctly."""
resolved = get_typed_annotation("_Potato", globals())
assert resolved is _Potato

Loading…
Cancel
Save