Browse Source

Fix get_model_fields to unwrap Annotated before issubclass check

When a pydantic RootModel is parameterised with Annotated[SomeModel, Field(...)],
pydantic stores the root field annotation verbatim as Annotated[SomeModel, FieldInfo(...)].
get_model_fields passed that annotation directly to lenient_issubclass, which returned
False for the Annotated wrapper (it is not a type). This caused model_config to be
forwarded to ModelField, which then passed it to TypeAdapter. Pydantic raises
PydanticUserError when config is supplied together with a BaseModel/dataclass type.

Fix: strip one level of Annotated wrapping before the issubclass check, using the
public get_origin / get_args API from the standard typing module.

Fixes #14805 (alternative to PR #14805 without private pydantic internals)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
pull/15115/head
Vishnu Kosuri 4 months ago
parent
commit
f83a382ee9
  1. 9
      fastapi/_compat/v2.py
  2. 54
      tests/test_compat.py

9
fastapi/_compat/v2.py

@ -383,7 +383,14 @@ def get_model_fields(model: type[BaseModel]) -> list[ModelField]:
model_fields: list[ModelField] = []
for name, field_info in model.model_fields.items():
type_ = field_info.annotation
if lenient_issubclass(type_, (BaseModel, dict)) or is_dataclass(type_):
# Unwrap Annotated[T, ...] to T before the issubclass check.
# For RootModel fields and similar, pydantic may store the annotation
# as Annotated[SomeBaseModel, FieldInfo(...)]. lenient_issubclass does
# not recognise the Annotated wrapper as a BaseModel subclass, so
# model_config would be passed to TypeAdapter, which Pydantic rejects
# when the inner type is a BaseModel/dataclass/TypedDict.
check_type = get_args(type_)[0] if get_origin(type_) is Annotated else type_
if lenient_issubclass(check_type, (BaseModel, dict)) or is_dataclass(check_type):
model_config = None
else:
model_config = model.model_config

54
tests/test_compat.py

@ -1,3 +1,5 @@
from typing import Annotated
from fastapi import FastAPI, UploadFile
from fastapi._compat import (
Undefined,
@ -5,7 +7,7 @@ from fastapi._compat import (
)
from fastapi._compat.shared import is_bytes_sequence_annotation
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field, RootModel
from pydantic.fields import FieldInfo
@ -130,3 +132,53 @@ def test_serialize_sequence_value_with_none_first_in_union():
result = v2.serialize_sequence_value(field=field, value=["x", "y"])
assert result == ["x", "y"]
assert isinstance(result, list)
def test_get_model_fields_annotated_basemodel():
"""
get_model_fields must not raise when a field annotation is Annotated[SomeBaseModel, ...].
RootModel stores its root field annotation verbatim, so for
RootModel[Annotated[Item, Field(...)]] the annotation is
Annotated[Item, FieldInfo(...)]. Before the fix, lenient_issubclass
returned False for the Annotated wrapper, causing model_config to be
forwarded to TypeAdapter, which Pydantic rejects for BaseModel types.
"""
from fastapi._compat import v2
class Item(BaseModel):
name: str
class Wrapper(RootModel[Annotated[Item, Field(description="an item")]]):
pass
# Must not raise PydanticUserError
fields = v2.get_model_fields(Wrapper)
assert len(fields) == 1
assert fields[0].name == "root"
def test_get_model_fields_annotated_basemodel_openapi():
"""
OpenAPI schema generation must not crash when a response model is a
RootModel whose root annotation is Annotated[SomeBaseModel, Field(...)].
"""
from fastapi.openapi.utils import get_openapi
class Item(BaseModel):
name: str
class Wrapper(RootModel[Annotated[Item, Field(description="an item")]]):
pass
app = FastAPI()
@app.get("/test", response_model=Wrapper)
def endpoint():
return Wrapper(root=Item(name="foo"))
client = TestClient(app)
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
schema = response.json()
assert "/test" in schema["paths"]

Loading…
Cancel
Save