diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index b9982e8c9..5d93b684a 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,13 @@ hide: ### Refactors +* ♻️ Refactor and simplify Pydantic v2 (and v1) compatibility internal utils. PR [#14862](https://github.com/fastapi/fastapi/pull/14862) by [@tiangolo](https://github.com/tiangolo). + +## 0.128.4 + +### Refactors + +* ♻️ Refactor internals, simplify Pydantic v2/v1 utils, `create_model_field`, better types for `lenient_issubclass`. PR [#14860](https://github.com/fastapi/fastapi/pull/14860) by [@tiangolo](https://github.com/tiangolo). * ♻️ Simplify internals, remove Pydantic v1 only logic, no longer needed. PR [#14857](https://github.com/fastapi/fastapi/pull/14857) by [@tiangolo](https://github.com/tiangolo). * ♻️ Refactor internals, cleanup unneeded Pydantic v1 specific logic. PR [#14856](https://github.com/fastapi/fastapi/pull/14856) by [@tiangolo](https://github.com/tiangolo). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index b23e3c42f..580919a81 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.128.3" +__version__ = "0.128.4" from starlette import status as status diff --git a/fastapi/_compat/__init__.py b/fastapi/_compat/__init__.py index 22bc28dec..4581c38c8 100644 --- a/fastapi/_compat/__init__.py +++ b/fastapi/_compat/__init__.py @@ -1,7 +1,14 @@ 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_pydantic_v1_model_class as is_pydantic_v1_model_class +from .shared import ( + field_annotation_is_scalar_sequence as field_annotation_is_scalar_sequence, +) +from .shared import field_annotation_is_sequence as field_annotation_is_sequence +from .shared import ( + is_bytes_or_nonable_bytes_annotation as is_bytes_or_nonable_bytes_annotation, +) +from .shared import is_bytes_sequence_annotation as is_bytes_sequence_annotation from .shared import is_pydantic_v1_model_instance as is_pydantic_v1_model_instance from .shared import ( is_uploadfile_or_nonable_uploadfile_annotation as is_uploadfile_or_nonable_uploadfile_annotation, @@ -12,27 +19,21 @@ from .shared import ( from .shared import lenient_issubclass as lenient_issubclass from .shared import sequence_types as sequence_types from .shared import value_is_sequence as value_is_sequence -from .v2 import BaseConfig as BaseConfig from .v2 import ModelField as ModelField from .v2 import PydanticSchemaGenerationError as PydanticSchemaGenerationError from .v2 import RequiredParam as RequiredParam from .v2 import Undefined as Undefined -from .v2 import UndefinedType as UndefinedType from .v2 import Url as Url -from .v2 import Validator as Validator -from .v2 import _regenerate_error_with_loc as _regenerate_error_with_loc from .v2 import copy_field_info as copy_field_info from .v2 import create_body_model as create_body_model from .v2 import evaluate_forwardref as evaluate_forwardref from .v2 import get_cached_model_fields as get_cached_model_fields from .v2 import get_definitions as get_definitions +from .v2 import get_flat_models_from_fields as get_flat_models_from_fields from .v2 import get_missing_field_error as get_missing_field_error +from .v2 import get_model_name_map as get_model_name_map from .v2 import get_schema_from_model_field as get_schema_from_model_field -from .v2 import is_bytes_field as is_bytes_field -from .v2 import is_bytes_sequence_field as is_bytes_sequence_field from .v2 import is_scalar_field as is_scalar_field -from .v2 import is_scalar_sequence_field as is_scalar_sequence_field -from .v2 import is_sequence_field as is_sequence_field from .v2 import serialize_sequence_value as serialize_sequence_value from .v2 import ( with_info_plain_validator_function as with_info_plain_validator_function, diff --git a/fastapi/_compat/shared.py b/fastapi/_compat/shared.py index fdda481b8..c009da8fd 100644 --- a/fastapi/_compat/shared.py +++ b/fastapi/_compat/shared.py @@ -8,6 +8,7 @@ from dataclasses import is_dataclass from typing import ( Annotated, Any, + TypeVar, Union, ) @@ -15,7 +16,9 @@ from fastapi.types import UnionType from pydantic import BaseModel from pydantic.version import VERSION as PYDANTIC_VERSION from starlette.datastructures import UploadFile -from typing_extensions import get_args, get_origin +from typing_extensions import TypeGuard, get_args, get_origin + +_T = TypeVar("_T") # Copy from Pydantic: pydantic/_internal/_typing_extra.py if sys.version_info < (3, 10): @@ -39,15 +42,13 @@ sequence_annotation_to_type = { deque: deque, } -sequence_types = tuple(sequence_annotation_to_type.keys()) - -Url: type[Any] +sequence_types: tuple[type[Any], ...] = tuple(sequence_annotation_to_type.keys()) -# Copy of Pydantic: pydantic/_internal/_utils.py +# Copy of Pydantic: pydantic/_internal/_utils.py with added TypeGuard def lenient_issubclass( - cls: Any, class_or_tuple: Union[type[Any], tuple[type[Any], ...], None] -) -> bool: + cls: Any, class_or_tuple: Union[type[_T], tuple[type[_T], ...], None] +) -> TypeGuard[type[_T]]: try: return isinstance(cls, type) and issubclass(cls, class_or_tuple) # type: ignore[arg-type] except TypeError: # pragma: no cover @@ -177,16 +178,26 @@ def is_uploadfile_sequence_annotation(annotation: Any) -> bool: def is_pydantic_v1_model_instance(obj: Any) -> bool: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - from pydantic import v1 + # TODO: remove this function once the required version of Pydantic fully + # removes pydantic.v1 + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + from pydantic import v1 + except ImportError: # pragma: no cover + return False return isinstance(obj, v1.BaseModel) def is_pydantic_v1_model_class(cls: Any) -> bool: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - from pydantic import v1 + # TODO: remove this function once the required version of Pydantic fully + # removes pydantic.v1 + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + from pydantic import v1 + except ImportError: # pragma: no cover + return False return lenient_issubclass(cls, v1.BaseModel) diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 57b3d94ff..87b9fb47f 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -12,7 +12,7 @@ from typing import ( cast, ) -from fastapi._compat import shared +from fastapi._compat import lenient_issubclass, shared from fastapi.openapi.constants import REF_TEMPLATE from fastapi.types import IncEx, ModelNameMap, UnionType from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, create_model @@ -23,29 +23,20 @@ from pydantic._internal._schema_generation_shared import ( # type: ignore[attr- GetJsonSchemaHandler as GetJsonSchemaHandler, ) from pydantic._internal._typing_extra import eval_type_lenient -from pydantic._internal._utils import lenient_issubclass as lenient_issubclass from pydantic.fields import FieldInfo as FieldInfo from pydantic.json_schema import GenerateJsonSchema as GenerateJsonSchema from pydantic.json_schema import JsonSchemaValue as JsonSchemaValue from pydantic_core import CoreSchema as CoreSchema -from pydantic_core import PydanticUndefined, PydanticUndefinedType +from pydantic_core import PydanticUndefined from pydantic_core import Url as Url +from pydantic_core.core_schema import ( + with_info_plain_validator_function as with_info_plain_validator_function, +) from typing_extensions import Literal, get_args, get_origin -try: - from pydantic_core.core_schema import ( - with_info_plain_validator_function as with_info_plain_validator_function, - ) -except ImportError: # pragma: no cover - from pydantic_core.core_schema import ( - general_plain_validator_function as with_info_plain_validator_function, # noqa: F401 - ) - RequiredParam = PydanticUndefined Undefined = PydanticUndefined -UndefinedType = PydanticUndefinedType evaluate_forwardref = eval_type_lenient -Validator = Any # TODO: remove when dropping support for Pydantic < v2.12.3 _Attrs = { @@ -87,14 +78,6 @@ def asdict(field_info: FieldInfo) -> dict[str, Any]: } -class BaseConfig: - pass - - -class ErrorWrapper(Exception): - pass - - @dataclass class ModelField: field_info: FieldInfo @@ -119,18 +102,10 @@ class ModelField: sa = self.field_info.serialization_alias return sa or None - @property - def required(self) -> bool: - return self.field_info.is_required() - @property def default(self) -> Any: return self.get_default() - @property - def type_(self) -> Any: - return self.field_info.annotation - def __post_init__(self) -> None: with warnings.catch_warnings(): # Pydantic >= 2.12.0 warns about field specific metadata that is unused @@ -143,8 +118,8 @@ class ModelField: warnings.simplefilter( "ignore", category=UnsupportedFieldAttributeWarning ) - # TODO: remove after dropping support for Python 3.8 and - # setting the min Pydantic to v2.12.3 that adds asdict() + # TODO: remove after setting the min Pydantic to v2.12.3 + # that adds asdict(), and use self.field_info.asdict() instead field_dict = asdict(self.field_info) annotated_args = ( field_dict["annotation"], @@ -284,9 +259,9 @@ def get_definitions( for model in flat_serialization_models ] flat_model_fields = flat_validation_model_fields + flat_serialization_model_fields - input_types = {f.type_ for f in fields} + input_types = {f.field_info.annotation for f in fields} unique_flat_model_fields = { - f for f in flat_model_fields if f.type_ not in input_types + f for f in flat_model_fields if f.field_info.annotation not in input_types } inputs = [ ( @@ -321,22 +296,6 @@ def is_scalar_field(field: ModelField) -> bool: ) and not isinstance(field.field_info, params.Body) -def is_sequence_field(field: ModelField) -> bool: - return shared.field_annotation_is_sequence(field.field_info.annotation) - - -def is_scalar_sequence_field(field: ModelField) -> bool: - return shared.field_annotation_is_scalar_sequence(field.field_info.annotation) - - -def is_bytes_field(field: ModelField) -> bool: - return shared.is_bytes_or_nonable_bytes_annotation(field.type_) - - -def is_bytes_sequence_field(field: ModelField) -> bool: - return shared.is_bytes_sequence_annotation(field.type_) - - def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo: cls = type(field_info) merged_field_info = cls.from_annotation(annotation) @@ -432,10 +391,11 @@ def get_flat_models_from_annotation( origin = get_origin(annotation) if origin is not None: for arg in get_args(annotation): - if lenient_issubclass(arg, (BaseModel, Enum)) and arg not in known_models: - known_models.add(arg) - if lenient_issubclass(arg, BaseModel): - get_flat_models_from_model(arg, known_models=known_models) + if lenient_issubclass(arg, (BaseModel, Enum)): + if arg not in known_models: + known_models.add(arg) # type: ignore[arg-type] + if lenient_issubclass(arg, BaseModel): + get_flat_models_from_model(arg, known_models=known_models) else: get_flat_models_from_annotation(arg, known_models=known_models) return known_models @@ -444,7 +404,7 @@ def get_flat_models_from_annotation( def get_flat_models_from_field( field: ModelField, known_models: TypeModelSet ) -> TypeModelSet: - field_type = field.type_ + field_type = field.field_info.annotation if lenient_issubclass(field_type, BaseModel): if field_type in known_models: return known_models diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index dd42371ec..80f9c76e9 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -25,13 +25,13 @@ from fastapi._compat import ( create_body_model, evaluate_forwardref, field_annotation_is_scalar, + field_annotation_is_scalar_sequence, + field_annotation_is_sequence, get_cached_model_fields, get_missing_field_error, - is_bytes_field, - is_bytes_sequence_field, + is_bytes_or_nonable_bytes_annotation, + is_bytes_sequence_annotation, is_scalar_field, - is_scalar_sequence_field, - is_sequence_field, is_uploadfile_or_nonable_uploadfile_annotation, is_uploadfile_sequence_annotation, lenient_issubclass, @@ -182,8 +182,10 @@ def _get_flat_fields_from_params(fields: list[ModelField]) -> list[ModelField]: if not fields: return fields first_field = fields[0] - if len(fields) == 1 and lenient_issubclass(first_field.type_, BaseModel): - fields_to_extract = get_cached_model_fields(first_field.type_) + if len(fields) == 1 and lenient_issubclass( + first_field.field_info.annotation, BaseModel + ): + fields_to_extract = get_cached_model_fields(first_field.field_info.annotation) return fields_to_extract return fields @@ -512,7 +514,6 @@ def analyze_param( type_=use_annotation_from_field_info, default=field_info.default, alias=alias, - required=field_info.default in (RequiredParam, Undefined), field_info=field_info, ) if is_path_param: @@ -522,12 +523,8 @@ def analyze_param( elif isinstance(field_info, params.Query): assert ( is_scalar_field(field) - or is_scalar_sequence_field(field) - or ( - lenient_issubclass(field.type_, BaseModel) - # For Pydantic v1 - and getattr(field, "shape", 1) == 1 - ) + or field_annotation_is_scalar_sequence(field.field_info.annotation) + or lenient_issubclass(field.field_info.annotation, BaseModel) ), f"Query parameter {param_name!r} must be one of the supported types" return ParamDetails(type_annotation=type_annotation, depends=depends, field=field) @@ -713,7 +710,7 @@ def _validate_value_with_model_field( *, field: ModelField, value: Any, values: dict[str, Any], loc: tuple[str, ...] ) -> tuple[Any, list[Any]]: if value is None: - if field.required: + if field.field_info.is_required(): return None, [get_missing_field_error(loc=loc)] else: return deepcopy(field.default), [] @@ -730,7 +727,7 @@ def _get_multidict_value( alias = alias or get_validation_alias(field) if ( (not _is_json_field(field)) - and is_sequence_field(field) + and field_annotation_is_sequence(field.field_info.annotation) and isinstance(values, (ImmutableMultiDict, Headers)) ): value = values.getlist(alias) @@ -743,9 +740,12 @@ def _get_multidict_value( and isinstance(value, str) # For type checks and value == "" ) - or (is_sequence_field(field) and len(value) == 0) + or ( + field_annotation_is_sequence(field.field_info.annotation) + and len(value) == 0 + ) ): - if field.required: + if field.field_info.is_required(): return else: return deepcopy(field.default) @@ -766,8 +766,10 @@ def request_params_to_args( fields_to_extract = fields single_not_embedded_field = False default_convert_underscores = True - if len(fields) == 1 and lenient_issubclass(first_field.type_, BaseModel): - fields_to_extract = get_cached_model_fields(first_field.type_) + if len(fields) == 1 and lenient_issubclass( + first_field.field_info.annotation, BaseModel + ): + fields_to_extract = get_cached_model_fields(first_field.field_info.annotation) single_not_embedded_field = True # If headers are in a Pydantic model, the way to disable convert_underscores # would be with Header(convert_underscores=False) at the Pydantic model level @@ -871,8 +873,8 @@ def _should_embed_body_fields(fields: list[ModelField]) -> bool: # otherwise it has to be embedded, so that the key value pair can be extracted if ( isinstance(first_field.field_info, params.Form) - and not lenient_issubclass(first_field.type_, BaseModel) - and not is_union_of_base_models(first_field.type_) + and not lenient_issubclass(first_field.field_info.annotation, BaseModel) + and not is_union_of_base_models(first_field.field_info.annotation) ): return True return False @@ -889,12 +891,12 @@ async def _extract_form_body( field_info = field.field_info if ( isinstance(field_info, params.File) - and is_bytes_field(field) + and is_bytes_or_nonable_bytes_annotation(field.field_info.annotation) and isinstance(value, UploadFile) ): value = await value.read() elif ( - is_bytes_sequence_field(field) + is_bytes_sequence_annotation(field.field_info.annotation) and isinstance(field_info, params.File) and value_is_sequence(value) ): @@ -941,10 +943,10 @@ async def request_body_to_args( if ( single_not_embedded_field - and lenient_issubclass(first_field.type_, BaseModel) + and lenient_issubclass(first_field.field_info.annotation, BaseModel) and isinstance(received_body, FormData) ): - fields_to_extract = get_cached_model_fields(first_field.type_) + fields_to_extract = get_cached_model_fields(first_field.field_info.annotation) if isinstance(received_body, FormData): body_to_process = await _extract_form_body(fields_to_extract, received_body) @@ -997,7 +999,9 @@ def get_body_field( BodyModel = create_body_model( fields=flat_dependant.body_params, model_name=model_name ) - required = any(True for f in flat_dependant.body_params if f.required) + required = any( + True for f in flat_dependant.body_params if f.field_info.is_required() + ) BodyFieldInfo_kwargs: dict[str, Any] = { "annotation": BodyModel, "alias": "body", @@ -1021,7 +1025,6 @@ def get_body_field( final_field = create_model_field( name="body", type_=BodyModel, - required=required, alias="body", field_info=BodyFieldInfo(**BodyFieldInfo_kwargs), ) diff --git a/fastapi/openapi/models.py b/fastapi/openapi/models.py index ac6a6d52c..095990639 100644 --- a/fastapi/openapi/models.py +++ b/fastapi/openapi/models.py @@ -143,10 +143,7 @@ class Schema(BaseModelWithConfig): else_: Optional["SchemaOrBool"] = Field(default=None, alias="else") dependentSchemas: Optional[dict[str, "SchemaOrBool"]] = None prefixItems: Optional[list["SchemaOrBool"]] = None - # TODO: uncomment and remove below when deprecating Pydantic v1 - # It generates a list of schemas for tuples, before prefixItems was available - # items: Optional["SchemaOrBool"] = None - items: Optional[Union["SchemaOrBool", list["SchemaOrBool"]]] = None + items: Optional["SchemaOrBool"] = None contains: Optional["SchemaOrBool"] = None properties: Optional[dict[str, "SchemaOrBool"]] = None patternProperties: Optional[dict[str, "SchemaOrBool"]] = None diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index c9b006a71..bcad0be75 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -10,12 +10,10 @@ from fastapi._compat import ( ModelField, Undefined, get_definitions, - get_schema_from_model_field, - lenient_issubclass, -) -from fastapi._compat.v2 import ( get_flat_models_from_fields, get_model_name_map, + get_schema_from_model_field, + lenient_issubclass, ) from fastapi.datastructures import DefaultPlaceholder from fastapi.dependencies.models import Dependant @@ -131,7 +129,7 @@ def _get_openapi_operation_parameters( default_convert_underscores = True if len(flat_dependant.header_params) == 1: first_field = flat_dependant.header_params[0] - if lenient_issubclass(first_field.type_, BaseModel): + if lenient_issubclass(first_field.field_info.annotation, BaseModel): default_convert_underscores = getattr( first_field.field_info, "convert_underscores", True ) @@ -163,7 +161,7 @@ def _get_openapi_operation_parameters( parameter = { "name": name, "in": param_type.value, - "required": param.required, + "required": param.field_info.is_required(), "schema": param_schema, } if field_info.description: @@ -200,7 +198,7 @@ def get_openapi_operation_request_body( ) field_info = cast(Body, body_field.field_info) request_media_type = field_info.media_type - required = body_field.required + required = body_field.field_info.is_required() request_body_oai: dict[str, Any] = {} if required: request_body_oai["required"] = required diff --git a/fastapi/routing.py b/fastapi/routing.py index 2cf590f75..4e679dc03 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -35,7 +35,6 @@ from fastapi import params from fastapi._compat import ( ModelField, Undefined, - annotation_is_pydantic_v1, lenient_issubclass, ) from fastapi.datastructures import Default, DefaultPlaceholder @@ -53,7 +52,6 @@ from fastapi.encoders import jsonable_encoder from fastapi.exceptions import ( EndpointContext, FastAPIError, - PydanticV1NotSupportedError, RequestValidationError, ResponseValidationError, WebSocketRequestValidationError, @@ -639,11 +637,7 @@ class APIRoute(routing.Route): assert is_body_allowed_for_status_code(status_code), ( f"Status code {status_code} must not have a response body" ) - if annotation_is_pydantic_v1(self.response_model): - raise PydanticV1NotSupportedError( - "pydantic.v1 models are no longer supported by FastAPI." - f" Please update the response model {self.response_model!r}." - ) + self.dependencies = list(dependencies or []) self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "") # if a "form feed" character (page break) is found in the description text, @@ -677,11 +671,6 @@ class APIRoute(routing.Route): f"Status code {additional_status_code} must not have a response body" ) response_name = f"Response_{additional_status_code}_{self.unique_id}" - if annotation_is_pydantic_v1(model): - raise PydanticV1NotSupportedError( - "pydantic.v1 models are no longer supported by FastAPI." - f" In responses={{}}, please update {model}." - ) response_field = create_model_field( name=response_name, type_=model, mode="serialization" ) diff --git a/fastapi/utils.py b/fastapi/utils.py index da11fe2c7..28c7cdfcc 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -9,12 +9,9 @@ from typing import ( import fastapi from fastapi._compat import ( - BaseConfig, ModelField, PydanticSchemaGenerationError, Undefined, - UndefinedType, - Validator, annotation_is_pydantic_v1, ) from fastapi.datastructures import DefaultPlaceholder, DefaultType @@ -63,26 +60,19 @@ _invalid_args_message = ( def create_model_field( name: str, type_: Any, - class_validators: Optional[dict[str, Validator]] = None, default: Optional[Any] = Undefined, - required: Union[bool, UndefinedType] = Undefined, - model_config: Union[type[BaseConfig], None] = None, field_info: Optional[FieldInfo] = None, alias: Optional[str] = None, mode: Literal["validation", "serialization"] = "validation", - version: Literal["1", "auto"] = "auto", ) -> ModelField: if annotation_is_pydantic_v1(type_): raise PydanticV1NotSupportedError( "pydantic.v1 models are no longer supported by FastAPI." f" Please update the response model {type_!r}." ) - class_validators = class_validators or {} - field_info = field_info or FieldInfo(annotation=type_, default=default, alias=alias) - kwargs = {"mode": mode, "name": name, "field_info": field_info} try: - return v2.ModelField(**kwargs) # type: ignore[arg-type] + return v2.ModelField(mode=mode, name=name, field_info=field_info) except PydanticSchemaGenerationError: raise fastapi.exceptions.FastAPIError( _invalid_args_message.format(type_=type_)