Fix Annotated ForwardRef dependencies with postponed annotations by abishekgiri · Pull Request #15234 · fastapi/fastapi
Summary
Fix dependency extraction and OpenAPI generation when using postponed annotations together with Annotated[...] and forward-referenced types.
Previously, when using from __future__ import annotations, FastAPI could leave the full annotation as an unresolved string (e.g. Annotated[Potato, Depends(get_potato)]). This prevented proper dependency extraction, causing the parameter to be treated as a required query parameter at runtime and leading to OpenAPI generation errors.
Changes
-
improve annotation resolution to preserve nested structure inside stringified annotations
-
resolve inner names as nested
ForwardRef(...)instead of keeping the entire annotation unresolved -
ensure
Annotated[..., Depends(...)]works correctly with forward-declared types -
add regression tests covering:
- postponed annotations (
from __future__ import annotations) - forward-referenced dependency types
- correct dependency extraction and OpenAPI generation
- postponed annotations (
Backward Compatibility
This change is backward compatible and only fixes incorrect handling of previously unsupported annotation patterns.
Verification
Tested with:
uv run pytest tests/test_stringified_annotation_forwardref_dependency.pyuv run pytest tests/test_stringified_annotations_simple.pyuv run pytest tests/test_stringified_annotation_dependency.py
Related
Fixes #13056
abishekgiri
changed the title
Fix Annotated ForwardRef dependencies with future annotations
Fix Annotated ForwardRef dependencies with postponed annotations
This PR fixes a bug in annotation resolution and dependency handling, so the appropriate label should be bug.
Could a maintainer or triager please add the bug label so the required checks can pass?
@abishekgiri, thanks for your interest!
Please, review existing PRs linked to the issue (including closed PRs, to understand the reasons they were closed) before opening your PRs. And provide an explanation why you think your approach is better.
@abishekgiri, thanks for your interest!
Please, review existing PRs linked to the issue (including closed PRs, to understand the reasons they were closed) before opening your PRs. And provide an explanation why you think your approach is better.
I reviewed the PRs linked to #13056: #14952, #14557, #14703, #14872, #15096, #15131, and #15142.
My understanding is:
- Add regression test for Annotated + ForwardRef OpenAPI ( #13056 ) #14952 adds only a regression test.
- Fix forward references inside Annotated dependencies #14703 moves resolution later via
get_type_hints(..., include_extras=True)inget_dependant(), but the exact reproducer still failed there. - Fix issue #13056: Handle ForwardRef in Annotated types #14872 and fix: resolve ForwardRef in get_typed_annotation for Annotated+Depends (#13056) #15142 try to resolve
ForwardReflater in the pipeline, but in this bug the outerAnnotated[...]structure has already been lost too early, so FastAPI never extractsDepends(...)correctly. - fix: resolve ForwardRef in Annotated types for OpenAPI schema generation #15131 addresses the later OpenAPI failure in the Pydantic compat layer, but it does not fix the earlier dependency-extraction problem and changes a much broader area.
- Fix Annotated with ForwardRef when using future annotations #14557 is closer, but it relies on manual parsing of
Annotated[...]strings plus substringeval, which felt heavier and more fragile than necessary. - Fix Annotated with ForwardRef when using future annotations #15096 is the closest implementation-wise. The main difference is that it replaces unresolved inner names with
Any.
I chose this approach because it fixes the problem at the earliest shared point: get_typed_annotation().
Instead of keeping the whole annotation unresolved, or converting missing inner names to Any, this PR evaluates with a lenient namespace that preserves unresolved inner names as nested ForwardRef(...) objects. That means FastAPI still sees the outer Annotated[...] structure, can still extract Depends(...), and does not lose the original type information unnecessarily.
I think that is better because:
- it is a smaller and more local change
- it fixes both runtime dependency extraction and OpenAPI generation from the same root cause
- it avoids special-case parsing of
Annotated[...] - it preserves unresolved types as
ForwardRef(...)instead of degrading them toAny
I also added a focused regression test for the exact issue shape from #13056, covering both runtime behavior and OpenAPI generation.
I looked into this and ended up with the same dict.__missing__ → ForwardRef idea.
One thing worth considering: using the lenient dict as a fallback instead of replacing the existing eval path. Right now you're routing all annotations through _LenientTypeResolutionDict, but evaluate_forwardref already works fine for the common case. The lenient path only needs to kick in when normal resolution returns an unresolved ForwardRef:
def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: if isinstance(annotation, str): annotation = ForwardRef(annotation) annotation = evaluate_forwardref(annotation, globalns, globalns) if isinstance(annotation, ForwardRef): try: annotation = eval( annotation.__forward_arg__, _LenientTypeResolutionDict(globalns), ) except Exception: pass if annotation is type(None): return None return annotation
Also — self[key] = value in __missing__ caches into the dict during eval. If evaluate_forwardref does multiple passes internally, a cached ForwardRef could shadow a name that would resolve on a later pass. Probably fine since the dict is single-use, but worth checking.
I looked into this and ended up with the same
dict.__missing__→ForwardRefidea.One thing worth considering: using the lenient dict as a fallback instead of replacing the existing eval path. Right now you're routing all annotations through
_LenientTypeResolutionDict, butevaluate_forwardrefalready works fine for the common case. The lenient path only needs to kick in when normal resolution returns an unresolvedForwardRef:def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: if isinstance(annotation, str): annotation = ForwardRef(annotation) annotation = evaluate_forwardref(annotation, globalns, globalns) if isinstance(annotation, ForwardRef): try: annotation = eval( annotation.__forward_arg__, _LenientTypeResolutionDict(globalns), ) except Exception: pass if annotation is type(None): return None return annotationAlso —
self[key] = valuein__missing__caches into the dict during eval. Ifevaluate_forwardrefdoes multiple passes internally, a cachedForwardRefcould shadow a name that would resolve on a later pass. Probably fine since the dict is single-use, but worth checking.
Thanks, that makes sense. I updated the implementation to keep the normal evaluate_forwardref(..., globalns, globalns) path first and only fall back to the lenient namespace when strict resolution still returns a ForwardRef.
I also removed the cache write from __missing__ so unresolved placeholders don’t get memoized during eval.
Added a focused unit test for the fallback branch and re-ran the regression tests plus mypy.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters