From 7b8992ab4bbbc78a83ba75720d513b0ab49b456a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Sat, 18 Oct 2025 04:45:08 +0000 Subject: [PATCH] fix(compat): route v1/v2 ModelField correctly; expose schema error; harden v2 fallback - add shared.is_v1_field_info() helper and use it in create_model_field - build v1_kwargs / v2_kwargs separately; avoid constructing v2 FieldInfo in v1 path - v2.ModelField: fallback to Any when field_info.annotation is absent (v1 FieldInfo) - expose PydanticSchemaGenerationError in v1 bridge (map to ConfigError on v1) - keep stubs minimal (.pyi) and py38-compatible (typing.*) --- fastapi/_compat/__init__.py | 2 ++ fastapi/_compat/shared.py | 25 ++++++++++++++++++++++ fastapi/_compat/v1.py | 24 ++++++++++++++++++++++ fastapi/_compat/v2.py | 19 ++++++----------- fastapi/utils.py | 41 ++++++++++++++++++++++++------------- 5 files changed, 84 insertions(+), 27 deletions(-) diff --git a/fastapi/_compat/__init__.py b/fastapi/_compat/__init__.py index 6674a2462..435b66c37 100644 --- a/fastapi/_compat/__init__.py +++ b/fastapi/_compat/__init__.py @@ -85,6 +85,7 @@ from .shared import PYDANTIC_V2 as PYDANTIC_V2 from .shared import PYDANTIC_VERSION_MINOR_TUPLE as PYDANTIC_VERSION_MINOR_TUPLE from .shared import annotation_is_pydantic_v1 as annotation_is_pydantic_v1 from .shared import field_annotation_is_scalar as field_annotation_is_scalar +from .shared import is_v1_field_info as is_v1_field_info from .shared import ( is_uploadfile_or_nonable_uploadfile_annotation as is_uploadfile_or_nonable_uploadfile_annotation, ) @@ -155,6 +156,7 @@ __all__ = [ "_is_undefined", "get_cached_model_fields", "get_compat_model_name_map", + "is_v1_field_info", "ModelField", "PYDANTIC_VERSION_MINOR_TUPLE", "annotation_is_pydantic_v1", diff --git a/fastapi/_compat/shared.py b/fastapi/_compat/shared.py index 29cfb11fd..19438d08e 100644 --- a/fastapi/_compat/shared.py +++ b/fastapi/_compat/shared.py @@ -18,6 +18,11 @@ from typing import ( from fastapi._compat import v1 as _v1 from fastapi.types import UnionType + +try: + from fastapi._compat import _v1_params +except Exception: # pragma: no cover + _v1_params = None # type: ignore[assignment] from pydantic import BaseModel from pydantic.version import VERSION as PYDANTIC_VERSION from starlette.datastructures import UploadFile @@ -124,6 +129,26 @@ def field_annotation_is_complex(annotation: Union[Type[Any], None]) -> bool: ) +def is_v1_field_info(field_info: Any) -> bool: + """Return True if this FieldInfo comes from Pydantic v1 (native or shim).""" + if field_info is None: + return False + + # v1 native FieldInfo + if isinstance(field_info, getattr(_v1, "FieldInfo", ())): + return True + + # shim _v1_params classes + if _v1_params is not None: + for name in ("Param", "Body", "Form", "File", "Query", "Path", "Header", "Cookie"): + cls = getattr(_v1_params, name, None) + if cls and isinstance(field_info, cls): + return True + + # wrapper that stores v1.FieldInfo internally + return getattr(field_info, "_fi", None) is not None + + def field_annotation_is_scalar(annotation: Any) -> bool: # handle Ellipsis here to make tuple[int, ...] work nicely return annotation is Ellipsis or not field_annotation_is_complex(annotation) diff --git a/fastapi/_compat/v1.py b/fastapi/_compat/v1.py index adbec897a..e96efef17 100644 --- a/fastapi/_compat/v1.py +++ b/fastapi/_compat/v1.py @@ -22,6 +22,22 @@ from typing_extensions import Literal # Legacy FastAPI sentinel used by v1 params RequiredParam = Ellipsis +# Compat: expose PydanticSchemaGenerationError for both v1 and v2 +try: + # pydantic v2 (caso alguém injete v2 por engano) + from pydantic.errors import ( + PydanticSchemaGenerationError as _PSGE, # type: ignore[attr-defined] + ) +except Exception: + try: + # pydantic v1: usar o erro mais próximo semanticamente + from pydantic.errors import ConfigError as _PSGE + except Exception: + class _PSGE(Exception): # fallback defensivo (não deve acontecer) + pass + +PydanticSchemaGenerationError = _PSGE + _pv1 = None _warned = False @@ -226,3 +242,11 @@ GetJsonSchemaHandler = Any JsonSchemaValue = Dict[str, Any] CoreSchema = Any Url = Any + +__all__ = [ + "PydanticSchemaGenerationError", + "RequiredParam", + "JsonSchemaValue", + "CoreSchema", + "Url", +] diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index ff1134029..0649ed12a 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -106,19 +106,12 @@ class ModelField: # NOTE: Pydantic v1.FieldInfo does not define .annotation (added in v2). # When bridging v1->v2, fall back to Any to avoid AttributeError at runtime. annotation = getattr(self.field_info, "annotation", None) - if annotation is not None: - annotated_type = Annotated[annotation, self.field_info] - else: - # Fallback for v1.FieldInfo (no .annotation) - # Use a simple type that TypeAdapter can handle - annotated_type = Annotated[str, self.field_info] - - try: - self._type_adapter: TypeAdapter[Any] = TypeAdapter(annotated_type) - except (AttributeError, TypeError, Exception): - # If TypeAdapter fails for any reason, use a simple fallback - # This handles cases where the annotation type is not compatible with TypeAdapter - self._type_adapter: TypeAdapter[Any] = TypeAdapter(str) + if annotation is None: + annotation = Any + + self._type_adapter: TypeAdapter[Any] = TypeAdapter( + Annotated[annotation, self.field_info] + ) def get_default(self) -> Any: if self.field_info.is_required(): diff --git a/fastapi/utils.py b/fastapi/utils.py index 1b4f419b3..7cda73bae 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -23,6 +23,7 @@ from fastapi._compat import ( Undefined, UndefinedType, Validator, + annotation_is_pydantic_v1, lenient_issubclass, ) from fastapi.datastructures import DefaultPlaceholder, DefaultType @@ -94,32 +95,44 @@ def create_model_field( ) -> ModelField: class_validators = class_validators or {} - v1_model_config = _get_v1().BaseConfig - v1_field_info = field_info or FieldInfo() - v1_kwargs = { + # Build v1 kwargs + v1_kwargs: Dict[str, Any] = { "name": name, - "field_info": v1_field_info, "type_": type_, "class_validators": class_validators, "default": default, "required": required, - "model_config": v1_model_config, + "model_config": model_config, + "field_info": field_info, "alias": alias, } - if PYDANTIC_V2 and version != "1": - from ._compat import v2 + # Detect v1 context + from fastapi._compat.shared import is_v1_field_info - field_info = field_info or FieldInfo( - annotation=type_, default=default, alias=alias - ) - kwargs = {"mode": mode, "name": name, "field_info": field_info} + is_v1_model = annotation_is_pydantic_v1(type_) or lenient_issubclass(type_, _get_v1().BaseModel) + is_v1_field = is_v1_field_info(field_info) + is_forced_v1 = (version == "1") + + # Route to appropriate ModelField + if is_v1_model or is_v1_field or is_forced_v1: + try: + return _get_v1().ModelField(**v1_kwargs) # type: ignore[no-any-return] + except RuntimeError: + raise fastapi.exceptions.FastAPIError(_invalid_args_message) from None + + # v2 path - create FieldInfo only for v2 + fi_v2 = field_info or FieldInfo(annotation=type_, default=default, alias=alias) + v2_kwargs: Dict[str, Any] = {"mode": mode, "name": name, "field_info": fi_v2} + + if PYDANTIC_V2: + from ._compat import v2 try: - return v2.ModelField(**kwargs) # type: ignore[return-value] + return v2.ModelField(**v2_kwargs) # type: ignore[return-value] except PydanticSchemaGenerationError: raise fastapi.exceptions.FastAPIError(_invalid_args_message) from None - # Pydantic v2 is not installed, but it's not a Pydantic v1 ModelField, it could be - # a Pydantic v1 type, like a constrained int + + # Fallback to v1 if v2 not available try: return _get_v1().ModelField(**v1_kwargs) # type: ignore[no-any-return] except RuntimeError: