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
12 changes: 12 additions & 0 deletions fastapi/dependencies/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,19 @@ 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)
if isinstance(annotation, ForwardRef):
annotation = evaluate_forwardref(annotation, globalns, globalns) # ty: ignore[deprecated]
# eval_type_lenient returns the ForwardRef unchanged when resolution
# fails, so a ForwardRef result means the type could not be resolved.
if isinstance(annotation, ForwardRef):
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
Expand Down
37 changes: 37 additions & 0 deletions tests/test_dependencies_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from typing import ForwardRef

import pytest
from fastapi.dependencies.utils import get_typed_annotation


Expand All @@ -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
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -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: # pragma: no cover
return Potato(color="red", size=5)


@app.get("/")
async def read_root(potato: Annotated[Potato, Depends(get_potato)]): # pragma: no cover
return {"Hello": "World"}


@dataclass # pragma: no cover
class Potato:
color: str
size: int
Original file line number Diff line number Diff line change
@@ -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)}
Original file line number Diff line number Diff line change
@@ -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: ...
Original file line number Diff line number Diff line change
@@ -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: ...
Original file line number Diff line number Diff line change
@@ -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: ...
Original file line number Diff line number Diff line change
@@ -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 # pragma: no cover
class Potato:
color: str
size: int
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)