From aab5905a07bb638ef2e37ff58791259c2cec57cb Mon Sep 17 00:00:00 2001 From: itsviseph Date: Sat, 4 Apr 2026 21:53:13 +0530 Subject: [PATCH 1/3] Preserve Annotated dependency metadata for stringified refs --- fastapi/dependencies/utils.py | 16 +++- ...est_stringified_annotations_forwardrefs.py | 79 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 tests/test_stringified_annotations_forwardrefs.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 6b14dac8dc55e..02fed2e429922 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -81,6 +81,13 @@ 'You can install "python-multipart" with: \n\n' "pip install python-multipart\n" ) + + +class _ForwardRefNamespace(dict[str, Any]): + def __missing__(self, key: str) -> Any: + value = ForwardRef(key) + self[key] = value + return value multipart_incorrect_install_error = ( 'Form data requires "python-multipart" to be installed. ' 'It seems you installed "multipart" instead. \n' @@ -244,8 +251,13 @@ def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: if isinstance(annotation, str): - annotation = ForwardRef(annotation) - annotation = evaluate_forwardref(annotation, globalns, globalns) # ty: ignore[deprecated] + try: + annotation = eval(annotation, globalns, globalns) + except NameError: + # Preserve the outer typing structure (e.g. Annotated[..., Depends(...)]) + # even when some inner symbols are not defined yet. + forwardref_globalns = _ForwardRefNamespace(globalns) + annotation = eval(annotation, forwardref_globalns, forwardref_globalns) if annotation is type(None): return None return annotation diff --git a/tests/test_stringified_annotations_forwardrefs.py b/tests/test_stringified_annotations_forwardrefs.py new file mode 100644 index 0000000000000..5c91a6d1ae582 --- /dev/null +++ b/tests/test_stringified_annotations_forwardrefs.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from .utils import needs_py310 + + +def _build_app(source: str): + namespace: dict[str, object] = {} + exec(source, namespace, namespace) + return namespace["app"] + + +@needs_py310 +def test_late_defined_annotated_dependency_forwardref_is_not_treated_as_query_param(): + app = _build_app(""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Depends, FastAPI + +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) + + response = client.get("/") + assert response.status_code == 200, response.text + assert response.json() == {"color": "red", "size": 10} + + operation = client.get("/openapi.json").json()["paths"]["/"]["get"] + assert "parameters" not in operation + + +@needs_py310 +def test_stringified_annotated_dependency_with_defined_type_remains_a_dependency(): + app = _build_app(""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Depends, FastAPI + +app = FastAPI() + +@dataclass +class Potato: + color: str + size: int + +def get_potato() -> Potato: + return Potato(color="gold", size=7) + +@app.get("/") +async def read_root(potato: Annotated["Potato", Depends(get_potato)]): + return {"color": potato.color, "size": potato.size} +""") + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 200, response.text + assert response.json() == {"color": "gold", "size": 7} + + operation = client.get("/openapi.json").json()["paths"]["/"]["get"] + assert "parameters" not in operation From f867a204915bc0f0bca989bb0ae095c18fd09b63 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:25:14 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 02fed2e429922..0477c75ddcc4c 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -33,7 +33,6 @@ Undefined, copy_field_info, create_body_model, - evaluate_forwardref, # ty: ignore[deprecated] field_annotation_is_scalar, field_annotation_is_scalar_sequence, field_annotation_is_sequence, @@ -88,6 +87,8 @@ def __missing__(self, key: str) -> Any: value = ForwardRef(key) self[key] = value return value + + multipart_incorrect_install_error = ( 'Form data requires "python-multipart" to be installed. ' 'It seems you installed "multipart" instead. \n' From a7e6ed35c7cf7333731ac72c778761435b39655b Mon Sep 17 00:00:00 2001 From: itsviseph Date: Sun, 5 Apr 2026 13:33:55 +0530 Subject: [PATCH 3/3] Add nested forward-ref regression coverage --- fastapi/dependencies/utils.py | 6 +++ ...est_stringified_annotations_forwardrefs.py | 41 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 0477c75ddcc4c..652df06cb62a6 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -1,3 +1,4 @@ +import builtins import dataclasses import inspect import sys @@ -84,6 +85,8 @@ class _ForwardRefNamespace(dict[str, Any]): def __missing__(self, key: str) -> Any: + if hasattr(builtins, key): + return getattr(builtins, key) value = ForwardRef(key) self[key] = value return value @@ -253,6 +256,9 @@ def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: if isinstance(annotation, str): try: + # These annotation strings come from Python's own annotation machinery, + # not directly from user input, so evaluating them here matches the + # standard runtime resolution path. annotation = eval(annotation, globalns, globalns) except NameError: # Preserve the outer typing structure (e.g. Annotated[..., Depends(...)]) diff --git a/tests/test_stringified_annotations_forwardrefs.py b/tests/test_stringified_annotations_forwardrefs.py index 5c91a6d1ae582..448d40c09bf50 100644 --- a/tests/test_stringified_annotations_forwardrefs.py +++ b/tests/test_stringified_annotations_forwardrefs.py @@ -77,3 +77,44 @@ async def read_root(potato: Annotated["Potato", Depends(get_potato)]): operation = client.get("/openapi.json").json()["paths"]["/"]["get"] assert "parameters" not in operation + + +@needs_py310 +def test_nested_forwardref_inside_annotated_dependency_preserves_structure(): + app = _build_app(""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Depends, FastAPI + +app = FastAPI() + +def get_potatoes() -> list[Potato]: + return [Potato(color="red", size=10), Potato(color="gold", size=7)] + +@app.get("/") +async def read_root(potatoes: Annotated[list[Potato], Depends(get_potatoes)]): + return { + "items": [{"color": potato.color, "size": potato.size} for potato in potatoes] + } + +@dataclass +class Potato: + color: str + size: int +""") + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 200, response.text + assert response.json() == { + "items": [ + {"color": "red", "size": 10}, + {"color": "gold", "size": 7}, + ] + } + + operation = client.get("/openapi.json").json()["paths"]["/"]["get"] + assert "parameters" not in operation