Browse Source

🔧 Add throwing error on unresolvable annotations and add comprehensive tests

pull/15307/head
Dima Bulavenko 3 months ago
parent
commit
fa599c92b3
  1. 2
      fastapi/_compat/__init__.py
  2. 3
      fastapi/_compat/v2.py
  3. 14
      fastapi/dependencies/utils.py
  4. 37
      tests/test_dependencies_utils.py
  5. 0
      tests/test_unresolvable_annotations/__init__.py
  6. 0
      tests/test_unresolvable_annotations/apps/__init__.py
  7. 23
      tests/test_unresolvable_annotations/apps/annotated_depends_late_type.py
  8. 19
      tests/test_unresolvable_annotations/apps/type_checking_dep_return_type_safe.py
  9. 17
      tests/test_unresolvable_annotations/apps/type_checking_in_dep_param.py
  10. 14
      tests/test_unresolvable_annotations/apps/type_checking_in_endpoint_param.py
  11. 14
      tests/test_unresolvable_annotations/apps/type_checking_in_return_type.py
  12. 17
      tests/test_unresolvable_annotations/apps/type_declared_after_endpoint.py
  13. 15
      tests/test_unresolvable_annotations/apps/valid_type_before_endpoint.py
  14. 74
      tests/test_unresolvable_annotations/test_unresolvable_annotations.py

2
fastapi/_compat/__init__.py

@ -26,7 +26,7 @@ from .v2 import Undefined as Undefined
from .v2 import Url as Url
from .v2 import copy_field_info as copy_field_info
from .v2 import create_body_model as create_body_model
from .v2 import evaluate_forwardref as evaluate_forwardref # ty: ignore[deprecated]
from .v2 import evaluate_forwardref as evaluate_forwardref
from .v2 import get_cached_model_fields as get_cached_model_fields
from .v2 import get_definitions as get_definitions
from .v2 import get_flat_models_from_fields as get_flat_models_from_fields

3
fastapi/_compat/v2.py

@ -26,6 +26,7 @@ from pydantic._internal._schema_generation_shared import ( # type: ignore[attr-
GetJsonSchemaHandler as GetJsonSchemaHandler,
)
from pydantic._internal._typing_extra import eval_type_lenient # ty: ignore[deprecated]
from pydantic._internal._typing_extra import try_eval_type # ty: ignore[deprecated]
from pydantic.fields import FieldInfo as FieldInfo
from pydantic.json_schema import GenerateJsonSchema as _GenerateJsonSchema
from pydantic.json_schema import JsonSchemaValue as JsonSchemaValue
@ -38,7 +39,7 @@ from pydantic_core.core_schema import (
RequiredParam = PydanticUndefined
Undefined = PydanticUndefined
evaluate_forwardref = eval_type_lenient # ty: ignore[deprecated]
evaluate_forwardref = try_eval_type
class GenerateJsonSchema(_GenerateJsonSchema):

14
fastapi/dependencies/utils.py

@ -33,7 +33,7 @@ from fastapi._compat import (
Undefined,
copy_field_info,
create_body_model,
evaluate_forwardref, # ty: ignore[deprecated]
evaluate_forwardref,
field_annotation_is_scalar,
field_annotation_is_scalar_sequence,
field_annotation_is_sequence,
@ -245,7 +245,17 @@ 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]
if isinstance(annotation, ForwardRef):
annotation, is_evaluated = evaluate_forwardref(annotation, globalns, globalns) # ty: ignore[deprecated]
if not is_evaluated:
raise NameError(
f"Could not resolve annotation {annotation.__forward_arg__!r}. "
f"Make sure the type is defined or imported before it is used "
f"in a route or dependency. Common causes: the type is only "
f"imported inside an `if TYPE_CHECKING:` block (it must be "
f"available at runtime), or the class is defined after the "
f"route that references it."
)
if annotation is type(None):
return None
return annotation

37
tests/test_dependencies_utils.py

@ -1,3 +1,6 @@
from typing import ForwardRef
import pytest
from fastapi.dependencies.utils import get_typed_annotation
@ -6,3 +9,37 @@ def test_get_typed_annotation():
annotation = "None"
typed_annotation = get_typed_annotation(annotation, globals())
assert typed_annotation is None
def test_get_typed_annotation_unresolvable_string():
"""A string annotation that can't be resolved should raise NameError."""
with pytest.raises(NameError, match="Could not resolve annotation 'NonExistent'"):
get_typed_annotation("NonExistent", {})
def test_get_typed_annotation_unresolvable_forward_ref():
"""A ForwardRef that can't be resolved should raise NameError."""
ref = ForwardRef("UnknownModel")
with pytest.raises(NameError, match="Could not resolve annotation 'UnknownModel'"):
get_typed_annotation(ref, {})
def test_get_typed_annotation_resolvable_string():
"""A string annotation that can be resolved should return the type."""
ns = {"int": int}
result = get_typed_annotation("int", ns)
assert result is int
def test_get_typed_annotation_resolvable_forward_ref():
"""A ForwardRef that can be resolved should return the type."""
ns = {"MyClass": str}
ref = ForwardRef("MyClass")
result = get_typed_annotation(ref, ns)
assert result is str
def test_get_typed_annotation_non_string_passthrough():
"""Non-string, non-ForwardRef annotations pass through unchanged."""
result = get_typed_annotation(int, {})
assert result is int

0
tests/test_unresolvable_annotations/__init__.py

0
tests/test_unresolvable_annotations/apps/__init__.py

23
tests/test_unresolvable_annotations/apps/annotated_depends_late_type.py

@ -0,0 +1,23 @@
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=5)
@app.get("/")
async def read_root(potato: Annotated[Potato, Depends(get_potato)]):
return {"Hello": "World"}
@dataclass
class Potato:
color: str
size: int

19
tests/test_unresolvable_annotations/apps/type_checking_dep_return_type_safe.py

@ -0,0 +1,19 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import Depends, FastAPI
if TYPE_CHECKING:
from sqlalchemy.orm import Session # pragma: no cover
app = FastAPI()
def get_db() -> Session:
return "fake_db" # type: ignore[return-value]
@app.get("/")
def read_root(db=Depends(get_db)) -> dict:
return {"db": str(db)}

17
tests/test_unresolvable_annotations/apps/type_checking_in_dep_param.py

@ -0,0 +1,17 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import Depends, FastAPI
if TYPE_CHECKING:
from pydantic import BaseModel # pragma: no cover
app = FastAPI()
def get_thing(item: BaseModel) -> None: ...
@app.get("/")
def read_root(thing=Depends(get_thing)) -> dict: ...

14
tests/test_unresolvable_annotations/apps/type_checking_in_endpoint_param.py

@ -0,0 +1,14 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import FastAPI
if TYPE_CHECKING:
from pydantic import BaseModel # pragma: no cover
app = FastAPI()
@app.get("/")
def read_root(item: BaseModel) -> dict: ...

14
tests/test_unresolvable_annotations/apps/type_checking_in_return_type.py

@ -0,0 +1,14 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import FastAPI
if TYPE_CHECKING:
from pydantic import BaseModel # pragma: no cover
app = FastAPI()
@app.get("/")
def read_root() -> BaseModel: ...

17
tests/test_unresolvable_annotations/apps/type_declared_after_endpoint.py

@ -0,0 +1,17 @@
from __future__ import annotations
from dataclasses import dataclass
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root(potato: Potato) -> Potato: ...
@dataclass
class Potato:
color: str
size: int

15
tests/test_unresolvable_annotations/apps/valid_type_before_endpoint.py

@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
@app.post("/")
def create_item(item: Item) -> Item:
return item

74
tests/test_unresolvable_annotations/test_unresolvable_annotations.py

@ -0,0 +1,74 @@
"""Tests that unresolvable annotations raise clear errors at route registration time.
Covers two common user mistakes:
1. Using a type imported only under `if TYPE_CHECKING:` in endpoint/dependency params.
2. Using a class that is defined after the route that references it.
"""
import importlib
import pytest
from fastapi.testclient import TestClient
APP_PREFIX = "tests.test_unresolvable_annotations.apps"
def test_type_declared_after_endpoint():
"""A class used as an endpoint param before it's defined should fail."""
with pytest.raises(NameError, match="Could not resolve annotation 'Potato'"):
importlib.import_module(f"{APP_PREFIX}.type_declared_after_endpoint")
def test_annotated_depends_late_type():
"""Annotated[Potato, Depends(get_potato)] with Potato defined after the route should fail."""
with pytest.raises(NameError, match="Could not resolve annotation"):
importlib.import_module(f"{APP_PREFIX}.annotated_depends_late_type")
def test_type_checking_import_in_endpoint_param():
"""TYPE_CHECKING-only import used as an endpoint parameter type should fail."""
with pytest.raises(NameError, match="Could not resolve annotation 'BaseModel'"):
importlib.import_module(f"{APP_PREFIX}.type_checking_in_endpoint_param")
def test_type_checking_import_in_dependency_param():
"""TYPE_CHECKING-only import used as a dependency parameter type should fail."""
with pytest.raises(NameError, match="Could not resolve annotation 'BaseModel'"):
importlib.import_module(f"{APP_PREFIX}.type_checking_in_dep_param")
def test_type_checking_import_in_return_type():
"""TYPE_CHECKING-only import used as an endpoint return type should fail."""
with pytest.raises(NameError, match="Could not resolve annotation 'BaseModel'"):
importlib.import_module(f"{APP_PREFIX}.type_checking_in_return_type")
def test_type_checking_dep_return_type_is_safe():
"""TYPE_CHECKING-only import used ONLY as a dependency return type is safe.
FastAPI doesn't inspect dependency return types for schema generation,
so this pattern should not raise an error.
"""
mod = importlib.import_module(f"{APP_PREFIX}.type_checking_dep_return_type_safe")
client = TestClient(mod.app)
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"db": "fake_db"}
def test_valid_type_before_endpoint():
"""Sanity check: a type defined before the route works normally."""
mod = importlib.import_module(f"{APP_PREFIX}.valid_type_before_endpoint")
client = TestClient(mod.app)
response = client.post("/", json={"name": "test"})
assert response.status_code == 200
assert response.json() == {"name": "test"}
def test_error_message_mentions_both_causes():
"""The error message should mention both TYPE_CHECKING and late declaration."""
from fastapi.dependencies.utils import get_typed_annotation
with pytest.raises(NameError, match="TYPE_CHECKING") as exc_info:
get_typed_annotation("Unresolvable", {})
assert "defined after the route" in str(exc_info.value)
Loading…
Cancel
Save