committed by
GitHub
3 changed files with 156 additions and 4 deletions
@ -0,0 +1,56 @@ |
|||||
|
# IMPORTANT: This future import MUST be at the top of the module. |
||||
|
# It is what causes annotations to be stored as strings (ForwardRef), |
||||
|
# which is exactly the bug scenario described in issue #13056. |
||||
|
from __future__ import annotations |
||||
|
|
||||
|
from dataclasses import dataclass |
||||
|
from typing import Annotated |
||||
|
|
||||
|
from fastapi import Depends, FastAPI |
||||
|
from fastapi.testclient import TestClient |
||||
|
|
||||
|
# ── App setup ───────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
|
||||
|
app = FastAPI() |
||||
|
|
||||
|
|
||||
|
@dataclass |
||||
|
class Potato: |
||||
|
color: str |
||||
|
size: int |
||||
|
|
||||
|
|
||||
|
def get_potato() -> Potato: |
||||
|
return Potato(color="red", size=10) |
||||
|
|
||||
|
|
||||
|
# The annotation "Potato" is a string here because of `from __future__ import |
||||
|
# annotations`. Before the fix, FastAPI would fail to resolve it when it is |
||||
|
# wrapped inside Annotated[...]. |
||||
|
@app.get("/") |
||||
|
def read_root(potato: Annotated[Potato, Depends(get_potato)]) -> dict: |
||||
|
return {"color": potato.color, "size": potato.size} |
||||
|
|
||||
|
|
||||
|
client = TestClient(app) |
||||
|
|
||||
|
|
||||
|
# ── Tests ────────────────────────────────────────────────────────────────────── |
||||
|
|
||||
|
|
||||
|
def test_annotated_forwardref_response() -> None: |
||||
|
"""Endpoint using Annotated[ForwardRef, Depends(...)] must return 200.""" |
||||
|
response = client.get("/") |
||||
|
assert response.status_code == 200 |
||||
|
assert response.json() == {"color": "red", "size": 10} |
||||
|
|
||||
|
|
||||
|
def test_annotated_forwardref_openapi_schema() -> None: |
||||
|
"""OpenAPI schema must be generated without errors and must not leak ForwardRef.""" |
||||
|
response = client.get("/openapi.json") |
||||
|
assert response.status_code == 200 |
||||
|
schema_text = response.text |
||||
|
assert "ForwardRef" not in schema_text, ( |
||||
|
"Unresolved ForwardRef leaked into the OpenAPI schema" |
||||
|
) |
||||
@ -1,8 +1,73 @@ |
|||||
|
# NOTE: Do NOT add `from __future__ import annotations` here. |
||||
|
# That future import would turn the string subscripts in Annotated[...] bodies |
||||
|
# into deferred references and break isinstance() checks in this file. |
||||
|
|
||||
|
from typing import Annotated, ForwardRef, get_args, get_origin |
||||
|
|
||||
|
from fastapi import params |
||||
from fastapi.dependencies.utils import get_typed_annotation |
from fastapi.dependencies.utils import get_typed_annotation |
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Existing coverage test |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
def test_get_typed_annotation(): |
def test_get_typed_annotation_none_string() -> None: |
||||
# For coverage |
# A bare "None" string should resolve to Python's NoneType (returned as None) |
||||
annotation = "None" |
typed_annotation = get_typed_annotation("None", globals()) |
||||
typed_annotation = get_typed_annotation(annotation, globals()) |
|
||||
assert typed_annotation is None |
assert typed_annotation is None |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Issue #13056 – ForwardRef inside Annotated |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
|
||||
|
class _Potato: |
||||
|
"""Stand-in model used by the tests below.""" |
||||
|
|
||||
|
|
||||
|
def _get_potato() -> "_Potato": |
||||
|
return _Potato() |
||||
|
|
||||
|
|
||||
|
def test_annotated_with_string_base_type() -> None: |
||||
|
"""Annotated["_Potato", Depends(...)] must resolve the base type.""" |
||||
|
dep = params.Depends(_get_potato) |
||||
|
annotation = Annotated["_Potato", dep] |
||||
|
resolved = get_typed_annotation(annotation, globals()) |
||||
|
|
||||
|
assert get_origin(resolved) is Annotated |
||||
|
args = get_args(resolved) |
||||
|
assert args[0] is _Potato, f"Expected _Potato, got {args[0]}" |
||||
|
assert isinstance(args[1], params.Depends) |
||||
|
|
||||
|
|
||||
|
def test_annotated_with_forwardref_base_type() -> None: |
||||
|
"""Annotated[ForwardRef("_Potato"), Depends(...)] must also resolve.""" |
||||
|
dep = params.Depends(_get_potato) |
||||
|
annotation = Annotated[ForwardRef("_Potato"), dep] |
||||
|
resolved = get_typed_annotation(annotation, globals()) |
||||
|
|
||||
|
assert get_origin(resolved) is Annotated |
||||
|
args = get_args(resolved) |
||||
|
assert args[0] is _Potato, f"Expected _Potato, got {args[0]}" |
||||
|
assert isinstance(args[1], params.Depends) |
||||
|
|
||||
|
|
||||
|
def test_annotated_with_already_resolved_type() -> None: |
||||
|
"""Annotated[_Potato, Depends(...)] (already resolved) must pass through unchanged.""" |
||||
|
dep = params.Depends(_get_potato) |
||||
|
annotation = Annotated[_Potato, dep] |
||||
|
resolved = get_typed_annotation(annotation, globals()) |
||||
|
|
||||
|
assert get_origin(resolved) is Annotated |
||||
|
args = get_args(resolved) |
||||
|
assert args[0] is _Potato |
||||
|
assert isinstance(args[1], params.Depends) |
||||
|
|
||||
|
|
||||
|
def test_bare_string_annotation() -> None: |
||||
|
"""A bare string annotation (non-Annotated) must still resolve correctly.""" |
||||
|
resolved = get_typed_annotation("_Potato", globals()) |
||||
|
assert resolved is _Potato |
||||
|
|||||
Loading…
Reference in new issue