From f83a382ee94c9e4a3ccbf1de7cf9cda928842456 Mon Sep 17 00:00:00 2001 From: Vishnu Kosuri Date: Sat, 14 Mar 2026 17:15:10 +0530 Subject: [PATCH] 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 --- fastapi/_compat/v2.py | 9 +++++++- tests/test_compat.py | 54 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 79fba93188..63d43b5573 100644 --- a/fastapi/_compat/v2.py +++ b/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 diff --git a/tests/test_compat.py b/tests/test_compat.py index 772bd305eb..d969f8d1a9 100644 --- a/tests/test_compat.py +++ b/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"]