Browse Source
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: Cursorpull/15096/head
2 changed files with 210 additions and 0 deletions
@ -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