Browse Source

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.*)
pull/14201/head
Roberto Bertó 10 months ago
parent
commit
7b8992ab4b
  1. 2
      fastapi/_compat/__init__.py
  2. 25
      fastapi/_compat/shared.py
  3. 24
      fastapi/_compat/v1.py
  4. 19
      fastapi/_compat/v2.py
  5. 41
      fastapi/utils.py

2
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 PYDANTIC_VERSION_MINOR_TUPLE as PYDANTIC_VERSION_MINOR_TUPLE
from .shared import annotation_is_pydantic_v1 as annotation_is_pydantic_v1 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 field_annotation_is_scalar as field_annotation_is_scalar
from .shared import is_v1_field_info as is_v1_field_info
from .shared import ( from .shared import (
is_uploadfile_or_nonable_uploadfile_annotation as is_uploadfile_or_nonable_uploadfile_annotation, is_uploadfile_or_nonable_uploadfile_annotation as is_uploadfile_or_nonable_uploadfile_annotation,
) )
@ -155,6 +156,7 @@ __all__ = [
"_is_undefined", "_is_undefined",
"get_cached_model_fields", "get_cached_model_fields",
"get_compat_model_name_map", "get_compat_model_name_map",
"is_v1_field_info",
"ModelField", "ModelField",
"PYDANTIC_VERSION_MINOR_TUPLE", "PYDANTIC_VERSION_MINOR_TUPLE",
"annotation_is_pydantic_v1", "annotation_is_pydantic_v1",

25
fastapi/_compat/shared.py

@ -18,6 +18,11 @@ from typing import (
from fastapi._compat import v1 as _v1 from fastapi._compat import v1 as _v1
from fastapi.types import UnionType 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 import BaseModel
from pydantic.version import VERSION as PYDANTIC_VERSION from pydantic.version import VERSION as PYDANTIC_VERSION
from starlette.datastructures import UploadFile 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: def field_annotation_is_scalar(annotation: Any) -> bool:
# handle Ellipsis here to make tuple[int, ...] work nicely # handle Ellipsis here to make tuple[int, ...] work nicely
return annotation is Ellipsis or not field_annotation_is_complex(annotation) return annotation is Ellipsis or not field_annotation_is_complex(annotation)

24
fastapi/_compat/v1.py

@ -22,6 +22,22 @@ from typing_extensions import Literal
# Legacy FastAPI sentinel used by v1 params # Legacy FastAPI sentinel used by v1 params
RequiredParam = Ellipsis 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 _pv1 = None
_warned = False _warned = False
@ -226,3 +242,11 @@ GetJsonSchemaHandler = Any
JsonSchemaValue = Dict[str, Any] JsonSchemaValue = Dict[str, Any]
CoreSchema = Any CoreSchema = Any
Url = Any Url = Any
__all__ = [
"PydanticSchemaGenerationError",
"RequiredParam",
"JsonSchemaValue",
"CoreSchema",
"Url",
]

19
fastapi/_compat/v2.py

@ -106,19 +106,12 @@ class ModelField:
# NOTE: Pydantic v1.FieldInfo does not define .annotation (added in v2). # NOTE: Pydantic v1.FieldInfo does not define .annotation (added in v2).
# When bridging v1->v2, fall back to Any to avoid AttributeError at runtime. # When bridging v1->v2, fall back to Any to avoid AttributeError at runtime.
annotation = getattr(self.field_info, "annotation", None) annotation = getattr(self.field_info, "annotation", None)
if annotation is not None: if annotation is None:
annotated_type = Annotated[annotation, self.field_info] annotation = Any
else:
# Fallback for v1.FieldInfo (no .annotation) self._type_adapter: TypeAdapter[Any] = TypeAdapter(
# Use a simple type that TypeAdapter can handle Annotated[annotation, self.field_info]
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)
def get_default(self) -> Any: def get_default(self) -> Any:
if self.field_info.is_required(): if self.field_info.is_required():

41
fastapi/utils.py

@ -23,6 +23,7 @@ from fastapi._compat import (
Undefined, Undefined,
UndefinedType, UndefinedType,
Validator, Validator,
annotation_is_pydantic_v1,
lenient_issubclass, lenient_issubclass,
) )
from fastapi.datastructures import DefaultPlaceholder, DefaultType from fastapi.datastructures import DefaultPlaceholder, DefaultType
@ -94,32 +95,44 @@ def create_model_field(
) -> ModelField: ) -> ModelField:
class_validators = class_validators or {} class_validators = class_validators or {}
v1_model_config = _get_v1().BaseConfig # Build v1 kwargs
v1_field_info = field_info or FieldInfo() v1_kwargs: Dict[str, Any] = {
v1_kwargs = {
"name": name, "name": name,
"field_info": v1_field_info,
"type_": type_, "type_": type_,
"class_validators": class_validators, "class_validators": class_validators,
"default": default, "default": default,
"required": required, "required": required,
"model_config": v1_model_config, "model_config": model_config,
"field_info": field_info,
"alias": alias, "alias": alias,
} }
if PYDANTIC_V2 and version != "1": # Detect v1 context
from ._compat import v2 from fastapi._compat.shared import is_v1_field_info
field_info = field_info or FieldInfo( is_v1_model = annotation_is_pydantic_v1(type_) or lenient_issubclass(type_, _get_v1().BaseModel)
annotation=type_, default=default, alias=alias is_v1_field = is_v1_field_info(field_info)
) is_forced_v1 = (version == "1")
kwargs = {"mode": mode, "name": name, "field_info": field_info}
# 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: try:
return v2.ModelField(**kwargs) # type: ignore[return-value] return v2.ModelField(**v2_kwargs) # type: ignore[return-value]
except PydanticSchemaGenerationError: except PydanticSchemaGenerationError:
raise fastapi.exceptions.FastAPIError(_invalid_args_message) from None 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: try:
return _get_v1().ModelField(**v1_kwargs) # type: ignore[no-any-return] return _get_v1().ModelField(**v1_kwargs) # type: ignore[no-any-return]
except RuntimeError: except RuntimeError:

Loading…
Cancel
Save