Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions fastapi/dependencies/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import builtins
import dataclasses
import inspect
import sys
Expand Down Expand Up @@ -33,7 +34,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,
Expand Down Expand Up @@ -81,6 +81,17 @@
'You can install "python-multipart" with: \n\n'
"pip install python-multipart\n"
)


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


multipart_incorrect_install_error = (
'Form data requires "python-multipart" to be installed. '
'It seems you installed "multipart" instead. \n'
Expand Down Expand Up @@ -244,8 +255,16 @@ 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:
# 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(...)])
# 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
Expand Down
120 changes: 120 additions & 0 deletions tests/test_stringified_annotations_forwardrefs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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


@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