From 3c49346238643287780d9f0d673f88b0d02df109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Fri, 6 Feb 2026 10:01:05 -0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20internals,?= =?UTF-8?q?=20cleanup=20unneeded=20Pydantic=20v1=20specific=20logic=20(#14?= =?UTF-8?q?856)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/_compat/__init__.py | 1 - fastapi/_compat/shared.py | 1 - fastapi/_compat/v2.py | 10 ++-------- fastapi/routing.py | 15 +-------------- fastapi/utils.py | 16 ---------------- 5 files changed, 3 insertions(+), 40 deletions(-) diff --git a/fastapi/_compat/__init__.py b/fastapi/_compat/__init__.py index 3dfaf9b712..b7701bd1a1 100644 --- a/fastapi/_compat/__init__.py +++ b/fastapi/_compat/__init__.py @@ -1,4 +1,3 @@ -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 diff --git a/fastapi/_compat/shared.py b/fastapi/_compat/shared.py index 68b9bbdf1e..fdda481b86 100644 --- a/fastapi/_compat/shared.py +++ b/fastapi/_compat/shared.py @@ -28,7 +28,6 @@ else: ) # pyright: ignore[reportAttributeAccessIssue] PYDANTIC_VERSION_MINOR_TUPLE = tuple(int(x) for x in PYDANTIC_VERSION.split(".")[:2]) -PYDANTIC_V2 = PYDANTIC_VERSION_MINOR_TUPLE[0] == 2 sequence_annotation_to_type = { diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index dae78a32e0..6c2eec58b5 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -500,14 +500,8 @@ def get_model_name_map(unique_models: TypeModelSet) -> dict[TypeModelOrEnum, str def get_compat_model_name_map(fields: list[ModelField]) -> ModelNameMap: - all_flat_models: TypeModelSet = set() - - v2_model_fields = [field for field in fields if isinstance(field, ModelField)] - v2_flat_models = get_flat_models_from_fields(v2_model_fields, known_models=set()) - all_flat_models = all_flat_models.union(v2_flat_models) - - model_name_map = get_model_name_map(all_flat_models) - return model_name_map + flat_models = get_flat_models_from_fields(fields, known_models=set()) + return get_model_name_map(flat_models) def get_flat_models_from_model( diff --git a/fastapi/routing.py b/fastapi/routing.py index c95f624bdf..b8d1f76248 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -59,7 +59,6 @@ from fastapi.exceptions import ( ) from fastapi.types import DecoratedCallable, IncEx from fastapi.utils import ( - create_cloned_field, create_model_field, generate_unique_id, get_value_or_default, @@ -652,20 +651,8 @@ class APIRoute(routing.Route): type_=self.response_model, mode="serialization", ) - # Create a clone of the field, so that a Pydantic submodel is not returned - # as is just because it's an instance of a subclass of a more limited class - # e.g. UserInDB (containing hashed_password) could be a subclass of User - # that doesn't have the hashed_password. But because it's a subclass, it - # would pass the validation and be returned as is. - # By being a new field, no inheritance will be passed as is. A new model - # will always be created. - # TODO: remove when deprecating Pydantic v1 - self.secure_cloned_response_field: Optional[ModelField] = ( - create_cloned_field(self.response_field) - ) else: self.response_field = None # type: ignore - self.secure_cloned_response_field = None 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, @@ -720,7 +707,7 @@ class APIRoute(routing.Route): body_field=self.body_field, status_code=self.status_code, response_class=self.response_class, - response_field=self.secure_cloned_response_field, + response_field=self.response_field, response_model_include=self.response_model_include, response_model_exclude=self.response_model_exclude, response_model_by_alias=self.response_model_by_alias, diff --git a/fastapi/utils.py b/fastapi/utils.py index 1c3a0881f7..da11fe2c77 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -1,13 +1,11 @@ import re import warnings -from collections.abc import MutableMapping from typing import ( TYPE_CHECKING, Any, Optional, Union, ) -from weakref import WeakKeyDictionary import fastapi from fastapi._compat import ( @@ -21,7 +19,6 @@ from fastapi._compat import ( ) from fastapi.datastructures import DefaultPlaceholder, DefaultType from fastapi.exceptions import FastAPIDeprecationWarning, PydanticV1NotSupportedError -from pydantic import BaseModel from pydantic.fields import FieldInfo from typing_extensions import Literal @@ -30,11 +27,6 @@ from ._compat import v2 if TYPE_CHECKING: # pragma: nocover from .routing import APIRoute -# Cache for `create_cloned_field` -_CLONED_TYPES_CACHE: MutableMapping[type[BaseModel], type[BaseModel]] = ( - WeakKeyDictionary() -) - def is_body_allowed_for_status_code(status_code: Union[int, str, None]) -> bool: if status_code is None: @@ -97,14 +89,6 @@ def create_model_field( ) from None -def create_cloned_field( - field: ModelField, - *, - cloned_types: Optional[MutableMapping[type[BaseModel], type[BaseModel]]] = None, -) -> ModelField: - return field - - def generate_operation_id_for_path( *, name: str, path: str, method: str ) -> str: # pragma: nocover From ac8362c447b94247e9f7268c3d1e2807818647ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 6 Feb 2026 18:01:30 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 75e0a5f562..d442ef9a19 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Refactors + +* ♻️ Refactor internals, cleanup unneeded Pydantic v1 specific logic. PR [#14856](https://github.com/fastapi/fastapi/pull/14856) by [@tiangolo](https://github.com/tiangolo). + ### Translations * 🌐 Update translations for fr (outdated pages). PR [#14839](https://github.com/fastapi/fastapi/pull/14839) by [@YuriiMotov](https://github.com/YuriiMotov). From cf55bade7ea6e4bf85a167e8aed1c6167e7a4196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Fri, 6 Feb 2026 11:04:24 -0800 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Simplify=20internals,?= =?UTF-8?q?=20remove=20Pydantic=20v1=20only=20logic,=20no=20longer=20neede?= =?UTF-8?q?d=20(#14857)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/_compat/__init__.py | 1 - fastapi/_compat/v2.py | 107 ++++------------------------------ fastapi/dependencies/utils.py | 8 +-- fastapi/openapi/utils.py | 8 ++- fastapi/routing.py | 7 +-- 5 files changed, 19 insertions(+), 112 deletions(-) diff --git a/fastapi/_compat/__init__.py b/fastapi/_compat/__init__.py index b7701bd1a1..22bc28dec3 100644 --- a/fastapi/_compat/__init__.py +++ b/fastapi/_compat/__init__.py @@ -25,7 +25,6 @@ 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_compat_model_name_map as get_compat_model_name_map from .v2 import get_definitions as get_definitions from .v2 import get_missing_field_error as get_missing_field_error from .v2 import get_schema_from_model_field as get_schema_from_model_field diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 6c2eec58b5..57b3d94ffc 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -1,7 +1,7 @@ import re import warnings from collections.abc import Sequence -from copy import copy, deepcopy +from copy import copy from dataclasses import dataclass, is_dataclass from enum import Enum from functools import lru_cache @@ -169,11 +169,11 @@ class ModelField: values: dict[str, Any] = {}, # noqa: B006 *, loc: tuple[Union[int, str], ...] = (), - ) -> tuple[Any, Union[list[dict[str, Any]], None]]: + ) -> tuple[Any, list[dict[str, Any]]]: try: return ( self._type_adapter.validate_python(value, from_attributes=True), - None, + [], ) except ValidationError as exc: return None, _regenerate_error_with_loc( @@ -305,94 +305,12 @@ def get_definitions( if "description" in item_def: item_description = cast(str, item_def["description"]).split("\f")[0] item_def["description"] = item_description - new_mapping, new_definitions = _remap_definitions_and_field_mappings( - model_name_map=model_name_map, - definitions=definitions, # type: ignore[arg-type] - field_mapping=field_mapping, - ) - return new_mapping, new_definitions - - -def _replace_refs( - *, - schema: dict[str, Any], - old_name_to_new_name_map: dict[str, str], -) -> dict[str, Any]: - new_schema = deepcopy(schema) - for key, value in new_schema.items(): - if key == "$ref": - value = schema["$ref"] - if isinstance(value, str): - ref_name = schema["$ref"].split("/")[-1] - if ref_name in old_name_to_new_name_map: - new_name = old_name_to_new_name_map[ref_name] - new_schema["$ref"] = REF_TEMPLATE.format(model=new_name) - continue - if isinstance(value, dict): - new_schema[key] = _replace_refs( - schema=value, - old_name_to_new_name_map=old_name_to_new_name_map, - ) - elif isinstance(value, list): - new_value = [] - for item in value: - if isinstance(item, dict): - new_item = _replace_refs( - schema=item, - old_name_to_new_name_map=old_name_to_new_name_map, - ) - new_value.append(new_item) - - else: - new_value.append(item) - new_schema[key] = new_value - return new_schema - - -def _remap_definitions_and_field_mappings( - *, - model_name_map: ModelNameMap, - definitions: dict[str, Any], - field_mapping: dict[ - tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue - ], -) -> tuple[ - dict[tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue], - dict[str, Any], -]: - old_name_to_new_name_map = {} - for field_key, schema in field_mapping.items(): - model = field_key[0].type_ - if model not in model_name_map or "$ref" not in schema: - continue - new_name = model_name_map[model] - old_name = schema["$ref"].split("/")[-1] - if old_name in {f"{new_name}-Input", f"{new_name}-Output"}: - continue - old_name_to_new_name_map[old_name] = new_name - - new_field_mapping: dict[ - tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue - ] = {} - for field_key, schema in field_mapping.items(): - new_schema = _replace_refs( - schema=schema, - old_name_to_new_name_map=old_name_to_new_name_map, - ) - new_field_mapping[field_key] = new_schema - - new_definitions = {} - for key, value in definitions.items(): - if key in old_name_to_new_name_map: - new_key = old_name_to_new_name_map[key] - else: - new_key = key - new_value = _replace_refs( - schema=value, - old_name_to_new_name_map=old_name_to_new_name_map, - ) - new_definitions[new_key] = new_value - return new_field_mapping, new_definitions + # definitions: dict[DefsRef, dict[str, Any]] + # but mypy complains about general str in other places that are not declared as + # DefsRef, although DefsRef is just str: + # DefsRef = NewType('DefsRef', str) + # So, a cast to simplify the types here + return field_mapping, cast(dict[str, dict[str, Any]], definitions) def is_scalar_field(field: ModelField) -> bool: @@ -441,7 +359,7 @@ def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]: return shared.sequence_annotation_to_type[origin_type](value) # type: ignore[no-any-return,index] -def get_missing_field_error(loc: tuple[str, ...]) -> dict[str, Any]: +def get_missing_field_error(loc: tuple[Union[int, str], ...]) -> dict[str, Any]: error = ValidationError.from_exception_data( "Field required", [{"type": "missing", "loc": loc, "input": {}}] ).errors(include_url=False)[0] @@ -499,11 +417,6 @@ def get_model_name_map(unique_models: TypeModelSet) -> dict[TypeModelOrEnum, str return {v: k for k, v in name_model_map.items()} -def get_compat_model_name_map(fields: list[ModelField]) -> ModelNameMap: - flat_models = get_flat_models_from_fields(fields, known_models=set()) - return get_model_name_map(flat_models) - - def get_flat_models_from_model( model: type["BaseModel"], known_models: Union[TypeModelSet, None] = None ) -> TypeModelSet: diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index b8f7f948c6..dd42371ecc 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -21,7 +21,6 @@ from fastapi._compat import ( ModelField, RequiredParam, Undefined, - _regenerate_error_with_loc, copy_field_info, create_body_model, evaluate_forwardref, @@ -718,12 +717,7 @@ def _validate_value_with_model_field( return None, [get_missing_field_error(loc=loc)] else: return deepcopy(field.default), [] - v_, errors_ = field.validate(value, values, loc=loc) - if isinstance(errors_, list): - new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=()) - return None, new_errors - else: - return v_, [] + return field.validate(value, values, loc=loc) def _is_json_field(field: ModelField) -> bool: diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 5736af3b78..c9b006a718 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -9,11 +9,14 @@ from fastapi import routing from fastapi._compat import ( ModelField, Undefined, - get_compat_model_name_map, get_definitions, get_schema_from_model_field, lenient_issubclass, ) +from fastapi._compat.v2 import ( + get_flat_models_from_fields, + get_model_name_map, +) from fastapi.datastructures import DefaultPlaceholder from fastapi.dependencies.models import Dependant from fastapi.dependencies.utils import ( @@ -512,7 +515,8 @@ def get_openapi( webhook_paths: dict[str, dict[str, Any]] = {} operation_ids: set[str] = set() all_fields = get_fields_from_routes(list(routes or []) + list(webhooks or [])) - model_name_map = get_compat_model_name_map(all_fields) + flat_models = get_flat_models_from_fields(all_fields, known_models=set()) + model_name_map = get_model_name_map(flat_models) field_mapping, definitions = get_definitions( fields=all_fields, model_name_map=model_name_map, diff --git a/fastapi/routing.py b/fastapi/routing.py index b8d1f76248..fe8d886093 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -277,15 +277,12 @@ async def serialize_response( endpoint_ctx: Optional[EndpointContext] = None, ) -> Any: if field: - errors = [] if is_coroutine: - value, errors_ = field.validate(response_content, {}, loc=("response",)) + value, errors = field.validate(response_content, {}, loc=("response",)) else: - value, errors_ = await run_in_threadpool( + value, errors = await run_in_threadpool( field.validate, response_content, {}, loc=("response",) ) - if isinstance(errors_, list): - errors.extend(errors_) if errors: ctx = endpoint_ctx or EndpointContext() raise ResponseValidationError( From cc6ced6345f6be18038437b73866294697294f3e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 6 Feb 2026 19:04:48 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index d442ef9a19..b9982e8c9c 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,7 @@ hide: ### Refactors +* ♻️ 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). ### Translations