mirror of https://github.com/tiangolo/fastapi.git
Fix Annotated with ForwardRef when using future annotations
When using `from __future__ import annotations` with `Annotated[SomeClass, Depends()]` where SomeClass is defined after the function, FastAPI was unable to properly resolve the forward reference and treated the parameter as a query parameter instead of a dependency. This fix adds special handling to partially resolve Annotated string annotations, extracting metadata (like Depends) even when the inner type cannot be fully resolved. Fixes #13056
This commit is contained in:
parent
330f0ba571
commit
9e7ca4d454
|
|
@ -18,6 +18,7 @@ from typing import (
|
|||
Type,
|
||||
Union,
|
||||
cast,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
import anyio
|
||||
|
|
@ -226,12 +227,41 @@ def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
|
|||
signature = _get_signature(call)
|
||||
unwrapped = inspect.unwrap(call)
|
||||
globalns = getattr(unwrapped, "__globals__", {})
|
||||
|
||||
# Try to use get_type_hints first for better forward reference resolution
|
||||
# This properly handles Annotated types with forward references when using
|
||||
# `from __future__ import annotations` (PEP 563)
|
||||
type_hints: Dict[str, Any] = {}
|
||||
try:
|
||||
# include_extras=True preserves Annotated metadata (like Depends)
|
||||
type_hints = get_type_hints(unwrapped, globalns=globalns, include_extras=True)
|
||||
except NameError:
|
||||
# If get_type_hints fails due to unresolved names, try to get updated
|
||||
# globalns from the module (in case classes were defined after the function)
|
||||
module_name = getattr(unwrapped, "__module__", None)
|
||||
if module_name:
|
||||
import importlib
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
updated_globalns = vars(module)
|
||||
type_hints = get_type_hints(
|
||||
unwrapped, globalns=updated_globalns, include_extras=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# Fall back to manual resolution if get_type_hints fails for other reasons
|
||||
pass
|
||||
|
||||
typed_params = [
|
||||
inspect.Parameter(
|
||||
name=param.name,
|
||||
kind=param.kind,
|
||||
default=param.default,
|
||||
annotation=get_typed_annotation(param.annotation, globalns),
|
||||
annotation=type_hints.get(param.name)
|
||||
if param.name in type_hints
|
||||
else get_typed_annotation(param.annotation, globalns),
|
||||
)
|
||||
for param in signature.parameters.values()
|
||||
]
|
||||
|
|
@ -241,6 +271,33 @@ def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
|
|||
|
||||
def get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any:
|
||||
if isinstance(annotation, str):
|
||||
# Special handling for Annotated types with forward references
|
||||
# When using `from __future__ import annotations`, the entire annotation
|
||||
# becomes a string like "Annotated[SomeClass, Depends(func)]"
|
||||
# We try to partially resolve it to preserve the Annotated structure
|
||||
if annotation.startswith("Annotated["):
|
||||
try:
|
||||
# Try to evaluate the full annotation with available globalns
|
||||
evaluated = evaluate_forwardref(
|
||||
ForwardRef(annotation), globalns, globalns
|
||||
)
|
||||
# If evaluation succeeds and it's an Annotated type, return it
|
||||
if get_origin(evaluated) is Annotated:
|
||||
return evaluated
|
||||
# If evaluation returns a ForwardRef, try partial resolution
|
||||
if isinstance(evaluated, ForwardRef) or (
|
||||
hasattr(evaluated, "__forward_arg__")
|
||||
):
|
||||
# Try to parse and construct Annotated manually
|
||||
partial = _try_resolve_annotated_string(annotation, globalns)
|
||||
if partial is not None:
|
||||
return partial
|
||||
except Exception:
|
||||
# Try partial resolution as fallback
|
||||
partial = _try_resolve_annotated_string(annotation, globalns)
|
||||
if partial is not None:
|
||||
return partial
|
||||
|
||||
annotation = ForwardRef(annotation)
|
||||
annotation = evaluate_forwardref(annotation, globalns, globalns)
|
||||
if annotation is type(None):
|
||||
|
|
@ -248,6 +305,75 @@ def get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any:
|
|||
return annotation
|
||||
|
||||
|
||||
def _try_resolve_annotated_string(
|
||||
annotation_str: str, globalns: Dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Try to partially resolve an Annotated string annotation.
|
||||
|
||||
When we have "Annotated[ForwardRef, metadata1, metadata2]" and ForwardRef
|
||||
can't be resolved, we still want to preserve the Annotated structure with
|
||||
the metadata (like Depends) so that FastAPI can extract dependencies.
|
||||
"""
|
||||
# Remove "Annotated[" prefix and trailing "]"
|
||||
if not annotation_str.startswith("Annotated[") or not annotation_str.endswith("]"):
|
||||
return None
|
||||
|
||||
inner = annotation_str[len("Annotated[") : -1]
|
||||
|
||||
# Find the first comma that's not inside brackets
|
||||
# This separates the type from the metadata
|
||||
bracket_depth = 0
|
||||
paren_depth = 0
|
||||
first_comma_idx = -1
|
||||
for i, char in enumerate(inner):
|
||||
if char == "[":
|
||||
bracket_depth += 1
|
||||
elif char == "]":
|
||||
bracket_depth -= 1
|
||||
elif char == "(":
|
||||
paren_depth += 1
|
||||
elif char == ")":
|
||||
paren_depth -= 1
|
||||
elif char == "," and bracket_depth == 0 and paren_depth == 0:
|
||||
first_comma_idx = i
|
||||
break
|
||||
|
||||
if first_comma_idx == -1:
|
||||
return None
|
||||
|
||||
type_part = inner[:first_comma_idx].strip()
|
||||
metadata_part = inner[first_comma_idx + 1 :].strip()
|
||||
|
||||
# Try to resolve the type part, keep as ForwardRef if it fails
|
||||
try:
|
||||
resolved_type = evaluate_forwardref(
|
||||
ForwardRef(type_part), globalns, globalns
|
||||
)
|
||||
except Exception:
|
||||
resolved_type = ForwardRef(type_part)
|
||||
|
||||
# Try to evaluate the metadata parts
|
||||
# The metadata is usually Depends(func), Query(), etc.
|
||||
try:
|
||||
# Create a safe evaluation context with FastAPI params
|
||||
eval_globals = {**globalns}
|
||||
|
||||
# Try to evaluate the metadata
|
||||
# Note: we use eval here because the metadata is a valid Python expression
|
||||
# that should be evaluable in the context of the module
|
||||
metadata_value = eval(metadata_part, eval_globals) # noqa: S307
|
||||
|
||||
# Construct the Annotated type
|
||||
if isinstance(resolved_type, ForwardRef):
|
||||
# Keep the ForwardRef but wrap in Annotated with the metadata
|
||||
return Annotated[Any, metadata_value] # type: ignore[return-value]
|
||||
else:
|
||||
return Annotated[resolved_type, metadata_value] # type: ignore[return-value]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_typed_return_annotation(call: Callable[..., Any]) -> Any:
|
||||
signature = _get_signature(call)
|
||||
unwrapped = inspect.unwrap(call)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
"""Test case for issue #13056: Can't use `Annotated` with `ForwardRef`"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
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)
|
||||
|
||||
|
||||
def test_annotated_forward_ref():
|
||||
"""Test that forward references work correctly with Annotated dependencies."""
|
||||
response = client.get('/')
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data == {'color': 'red', 'size': 10}
|
||||
|
||||
|
||||
def test_openapi_schema():
|
||||
"""Test that OpenAPI schema is generated correctly."""
|
||||
response = client.get('/openapi.json')
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
# The root path should NOT have query parameters for potato
|
||||
# It should only be a dependency
|
||||
root_path = schema['paths']['/']['get']
|
||||
# Check that potato is not a query parameter
|
||||
parameters = root_path.get('parameters', [])
|
||||
potato_params = [p for p in parameters if 'potato' in p.get('name', '').lower()]
|
||||
assert len(potato_params) == 0, f"Potato should not appear as a query parameter: {potato_params}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_annotated_forward_ref()
|
||||
test_openapi_schema()
|
||||
print("All tests passed!")
|
||||
Loading…
Reference in New Issue