File py314.patch of Package python-pydantic
From 9452e13c571db7d31051768c3b4d47a6e2ceea7d Mon Sep 17 00:00:00 2001
From: Victorien <65306057+Viicos@users.noreply.github.com>
Date: Thu, 10 Jul 2025 11:31:03 +0200
Subject: [PATCH] Add initial support for Python 3.14 (#11991)
Adds basic support for Python 3.14. Deferred annotations work for simple cases, but will need to be improved in the future.
---
.github/workflows/ci.yml | 11 +-
.github/workflows/integration.yml | 4 +-
docs/migration.md | 4 +-
pydantic/_internal/_config.py | 10 +-
pydantic/_internal/_fields.py | 7 +-
pydantic/_internal/_generics.py | 7 +-
pydantic/_internal/_model_construction.py | 24 +-
pydantic/_internal/_typing_extra.py | 36 +-
pydantic/dataclasses.py | 13 +-
pyproject.toml | 3 +
tests/test_dataclasses.py | 11 +-
tests/test_deferred_annotations.py | 81 ++++
tests/test_forward_ref.py | 15 -
tests/test_model_signature.py | 2 +-
tests/test_pickle.py | 12 +-
tests/test_v1.py | 2 +
uv.lock | 512 ++++++++++++----------
17 files changed, 469 insertions(+), 285 deletions(-)
create mode 100644 tests/test_deferred_annotations.py
Index: pydantic-2.11.9/docs/migration.md
===================================================================
--- pydantic-2.11.9.orig/docs/migration.md
+++ pydantic-2.11.9/docs/migration.md
@@ -188,7 +188,7 @@ to help ease migration, but calling them
If you'd still like to use said arguments, you can use [this workaround](https://github.com/pydantic/pydantic/issues/8825#issuecomment-1946206415).
* JSON serialization of non-string key values is generally done with `str(key)`, leading to some changes in behavior such as the following:
-```python
+```python {test="skip"}
from typing import Optional
from pydantic import BaseModel as V2BaseModel
@@ -218,7 +218,7 @@ print(v2_model.model_dump_json())
* `model_dump_json()` results are compacted in order to save space, and don't always exactly match that of `json.dumps()` output.
That being said, you can easily modify the separators used in `json.dumps()` results in order to align the two outputs:
-```python
+```python {test="skip"}
import json
from pydantic import BaseModel as V2BaseModel
Index: pydantic-2.11.9/pydantic/_internal/_config.py
===================================================================
--- pydantic-2.11.9.orig/pydantic/_internal/_config.py
+++ pydantic-2.11.9/pydantic/_internal/_config.py
@@ -98,7 +98,13 @@ class ConfigWrapper:
self.config_dict = cast(ConfigDict, config)
@classmethod
- def for_model(cls, bases: tuple[type[Any], ...], namespace: dict[str, Any], kwargs: dict[str, Any]) -> Self:
+ def for_model(
+ cls,
+ bases: tuple[type[Any], ...],
+ namespace: dict[str, Any],
+ raw_annotations: dict[str, Any],
+ kwargs: dict[str, Any],
+ ) -> Self:
"""Build a new `ConfigWrapper` instance for a `BaseModel`.
The config wrapper built based on (in descending order of priority):
@@ -109,6 +115,7 @@ class ConfigWrapper:
Args:
bases: A tuple of base classes.
namespace: The namespace of the class being created.
+ raw_annotations: The (non-evaluated) annotations of the model.
kwargs: The kwargs passed to the class being created.
Returns:
@@ -123,7 +130,6 @@ class ConfigWrapper:
config_class_from_namespace = namespace.get('Config')
config_dict_from_namespace = namespace.get('model_config')
- raw_annotations = namespace.get('__annotations__', {})
if raw_annotations.get('model_config') and config_dict_from_namespace is None:
raise PydanticUserError(
'`model_config` cannot be used as a model field name. Use `model_config` for model configuration.',
Index: pydantic-2.11.9/pydantic/_internal/_fields.py
===================================================================
--- pydantic-2.11.9.orig/pydantic/_internal/_fields.py
+++ pydantic-2.11.9/pydantic/_internal/_fields.py
@@ -119,7 +119,8 @@ def collect_model_fields( # noqa: C901
# https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older
# annotations is only used for finding fields in parent classes
- annotations = cls.__dict__.get('__annotations__', {})
+ annotations = _typing_extra.safe_get_annotations(cls)
+
fields: dict[str, FieldInfo] = {}
class_vars: set[str] = set()
@@ -375,7 +376,9 @@ def collect_dataclass_fields(
with ns_resolver.push(base):
for ann_name, dataclass_field in dataclass_fields.items():
- if ann_name not in base.__dict__.get('__annotations__', {}):
+ base_anns = _typing_extra.safe_get_annotations(base)
+
+ if ann_name not in base_anns:
# `__dataclass_fields__`contains every field, even the ones from base classes.
# Only collect the ones defined on `base`.
continue
Index: pydantic-2.11.9/pydantic/_internal/_generics.py
===================================================================
--- pydantic-2.11.9.orig/pydantic/_internal/_generics.py
+++ pydantic-2.11.9/pydantic/_internal/_generics.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import operator
import sys
import types
import typing
@@ -7,6 +8,7 @@ from collections import ChainMap
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
+from functools import reduce
from itertools import zip_longest
from types import prepare_class
from typing import TYPE_CHECKING, Annotated, Any, TypeVar
@@ -21,9 +23,6 @@ from ._core_utils import get_type_ref
from ._forward_ref import PydanticRecursiveRef
from ._utils import all_identical, is_model_class
-if sys.version_info >= (3, 10):
- from typing import _UnionGenericAlias # type: ignore[attr-defined]
-
if TYPE_CHECKING:
from ..main import BaseModel
@@ -311,7 +310,7 @@ def replace_types(type_: Any, type_map:
# PEP-604 syntax (Ex.: list | str) is represented with a types.UnionType object that does not have __getitem__.
# We also cannot use isinstance() since we have to compare types.
if sys.version_info >= (3, 10) and origin_type is types.UnionType:
- return _UnionGenericAlias(origin_type, resolved_type_args)
+ return reduce(operator.or_, resolved_type_args)
# NotRequired[T] and Required[T] don't support tuple type resolved_type_args, hence the condition below
return origin_type[resolved_type_args[0] if len(resolved_type_args) == 1 else resolved_type_args]
Index: pydantic-2.11.9/pydantic/_internal/_model_construction.py
===================================================================
--- pydantic-2.11.9.orig/pydantic/_internal/_model_construction.py
+++ pydantic-2.11.9/pydantic/_internal/_model_construction.py
@@ -105,12 +105,29 @@ class ModelMetaclass(ABCMeta):
# that `BaseModel` itself won't have any bases, but any subclass of it will, to determine whether the `__new__`
# call we're in the middle of is for the `BaseModel` class.
if bases:
+ raw_annotations: dict[str, Any]
+ if sys.version_info >= (3, 14):
+ if (
+ '__annotations__' in namespace
+ ): # `from __future__ import annotations` was used in the model's module
+ raw_annotations = namespace['__annotations__']
+ else:
+ # See https://docs.python.org/3.14/library/annotationlib.html#using-annotations-in-a-metaclass:
+ from annotationlib import Format, call_annotate_function, get_annotate_from_class_namespace
+
+ if annotate := get_annotate_from_class_namespace(namespace):
+ raw_annotations = call_annotate_function(annotate, format=Format.FORWARDREF)
+ else:
+ raw_annotations = {}
+ else:
+ raw_annotations = namespace.get('__annotations__', {})
+
base_field_names, class_vars, base_private_attributes = mcs._collect_bases_data(bases)
- config_wrapper = ConfigWrapper.for_model(bases, namespace, kwargs)
+ config_wrapper = ConfigWrapper.for_model(bases, namespace, raw_annotations, kwargs)
namespace['model_config'] = config_wrapper.config_dict
private_attributes = inspect_namespace(
- namespace, config_wrapper.ignored_types, class_vars, base_field_names
+ namespace, raw_annotations, config_wrapper.ignored_types, class_vars, base_field_names
)
if private_attributes or base_private_attributes:
original_model_post_init = get_model_post_init(namespace, bases)
@@ -365,6 +382,7 @@ def get_model_post_init(namespace: dict[
def inspect_namespace( # noqa C901
namespace: dict[str, Any],
+ raw_annotations: dict[str, Any],
ignored_types: tuple[type[Any], ...],
base_class_vars: set[str],
base_class_fields: set[str],
@@ -375,6 +393,7 @@ def inspect_namespace( # noqa C901
Args:
namespace: The attribute dictionary of the class to be created.
+ raw_annotations: The (non-evaluated) annotations of the model.
ignored_types: A tuple of ignore types.
base_class_vars: A set of base class class variables.
base_class_fields: A set of base class fields.
@@ -396,7 +415,6 @@ def inspect_namespace( # noqa C901
all_ignored_types = ignored_types + default_ignored_types()
private_attributes: dict[str, ModelPrivateAttr] = {}
- raw_annotations = namespace.get('__annotations__', {})
if '__root__' in raw_annotations or '__root__' in namespace:
raise TypeError("To define root models, use `pydantic.RootModel` rather than a field called '__root__'")
Index: pydantic-2.11.9/pydantic/_internal/_typing_extra.py
===================================================================
--- pydantic-2.11.9.orig/pydantic/_internal/_typing_extra.py
+++ pydantic-2.11.9/pydantic/_internal/_typing_extra.py
@@ -26,6 +26,9 @@ else:
from types import EllipsisType as EllipsisType
from types import NoneType as NoneType
+if sys.version_info >= (3, 14):
+ import annotationlib
+
if TYPE_CHECKING:
from pydantic import BaseModel
@@ -289,6 +292,19 @@ def _type_convert(arg: Any) -> Any:
return arg
+def safe_get_annotations(cls: type[Any]) -> dict[str, Any]:
+ """Get the annotations for the provided class, accounting for potential deferred forward references.
+
+ Starting with Python 3.14, accessing the `__annotations__` attribute might raise a `NameError` if
+ a referenced symbol isn't defined yet. In this case, we return the annotation in the *forward ref*
+ format.
+ """
+ if sys.version_info >= (3, 14):
+ return annotationlib.get_annotations(cls, format=annotationlib.Format.FORWARDREF)
+ else:
+ return cls.__dict__.get('__annotations__', {})
+
+
def get_model_type_hints(
obj: type[BaseModel],
*,
@@ -309,9 +325,14 @@ def get_model_type_hints(
ns_resolver = ns_resolver or NsResolver()
for base in reversed(obj.__mro__):
- ann: dict[str, Any] | None = base.__dict__.get('__annotations__')
- if not ann or isinstance(ann, types.GetSetDescriptorType):
+ # For Python 3.14, we could also use `Format.VALUE` and pass the globals/locals
+ # from the ns_resolver, but we want to be able to know which specific field failed
+ # to evaluate:
+ ann = safe_get_annotations(base)
+
+ if not ann:
continue
+
with ns_resolver.push(base):
globalns, localns = ns_resolver.types_namespace
for name, value in ann.items():
@@ -341,13 +362,18 @@ def get_cls_type_hints(
obj: The class to inspect.
ns_resolver: A namespace resolver instance to use. Defaults to an empty instance.
"""
- hints: dict[str, Any] | dict[str, tuple[Any, bool]] = {}
+ hints: dict[str, Any] = {}
ns_resolver = ns_resolver or NsResolver()
for base in reversed(obj.__mro__):
- ann: dict[str, Any] | None = base.__dict__.get('__annotations__')
- if not ann or isinstance(ann, types.GetSetDescriptorType):
+ # For Python 3.14, we could also use `Format.VALUE` and pass the globals/locals
+ # from the ns_resolver, but we want to be able to know which specific field failed
+ # to evaluate:
+ ann = safe_get_annotations(base)
+
+ if not ann:
continue
+
with ns_resolver.push(base):
globalns, localns = ns_resolver.types_namespace
for name, value in ann.items():
Index: pydantic-2.11.9/pydantic/dataclasses.py
===================================================================
--- pydantic-2.11.9.orig/pydantic/dataclasses.py
+++ pydantic-2.11.9/pydantic/dataclasses.py
@@ -157,7 +157,12 @@ def dataclass(
`x: int = dataclasses.field(default=pydantic.Field(..., kw_only=True), kw_only=True)`
"""
for annotation_cls in cls.__mro__:
- annotations: dict[str, Any] = getattr(annotation_cls, '__annotations__', {})
+ if sys.version_info >= (3, 14):
+ from annotationlib import Format, get_annotations
+
+ annotations = get_annotations(annotation_cls, format=Format.FORWARDREF)
+ else:
+ annotations: dict[str, Any] = getattr(annotation_cls, '__annotations__', {})
for field_name in annotations:
field_value = getattr(cls, field_name, None)
# Process only if this is an instance of `FieldInfo`.
@@ -176,9 +181,9 @@ def dataclass(
field_args['repr'] = field_value.repr
setattr(cls, field_name, dataclasses.field(**field_args))
- # In Python 3.9, when subclassing, information is pulled from cls.__dict__['__annotations__']
- # for annotations, so we must make sure it's initialized before we add to it.
- if cls.__dict__.get('__annotations__') is None:
+ if sys.version_info < (3, 10) and cls.__dict__.get('__annotations__') is None:
+ # In Python 3.9, when a class doesn't have any annotations, accessing `__annotations__`
+ # raises an `AttributeError`.
cls.__annotations__ = {}
cls.__annotations__[field_name] = annotations[field_name]
Index: pydantic-2.11.9/pyproject.toml
===================================================================
--- pydantic-2.11.9.orig/pyproject.toml
+++ pydantic-2.11.9/pyproject.toml
@@ -32,6 +32,7 @@ classifiers = [
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
+ 'Programming Language :: Python :: 3.14',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'License :: OSI Approved :: MIT License',
@@ -220,6 +221,8 @@ pydocstyle = { convention = 'google' }
'docs/*' = ['D']
'pydantic/__init__.py' = ['F405', 'F403', 'D']
'tests/test_forward_ref.py' = ['F821']
+# We can't configure a specific Python version per file (this one only supports 3.14+):
+'tests/test_deferred_annotations.py' = ['F821', 'F841']
'tests/*' = ['D', 'B', 'C4']
'pydantic/deprecated/*' = ['D', 'PYI']
'pydantic/color.py' = ['PYI']
Index: pydantic-2.11.9/tests/test_dataclasses.py
===================================================================
--- pydantic-2.11.9.orig/tests/test_dataclasses.py
+++ pydantic-2.11.9/tests/test_dataclasses.py
@@ -30,6 +30,7 @@ from pydantic import (
BaseModel,
BeforeValidator,
ConfigDict,
+ Field,
PydanticDeprecatedSince20,
PydanticUndefinedAnnotation,
PydanticUserError,
@@ -45,7 +46,6 @@ from pydantic import (
)
from pydantic._internal._mock_val_ser import MockValSer
from pydantic.dataclasses import is_pydantic_dataclass, rebuild_dataclass
-from pydantic.fields import Field, FieldInfo
from pydantic.json_schema import model_json_schema
@@ -2072,15 +2072,14 @@ def test_inheritance_replace(decorator1:
def test_dataclasses_inheritance_default_value_is_not_deleted(
decorator1: Callable[[Any], Any], default: Literal[1]
) -> None:
- if decorator1 is dataclasses.dataclass and isinstance(default, FieldInfo):
- pytest.skip(reason="stdlib dataclasses don't support Pydantic fields")
-
@decorator1
class Parent:
a: int = default
- assert Parent.a == 1
- assert Parent().a == 1
+ # stdlib dataclasses don't support Pydantic's `Field()`:
+ if decorator1 is pydantic.dataclasses.dataclass:
+ assert Parent.a == 1
+ assert Parent().a == 1
@pydantic.dataclasses.dataclass
class Child(Parent):
Index: pydantic-2.11.9/tests/test_deferred_annotations.py
===================================================================
--- /dev/null
+++ pydantic-2.11.9/tests/test_deferred_annotations.py
@@ -0,0 +1,81 @@
+"""Tests related to deferred evaluation of annotations introduced in Python 3.14 by PEP 649 and 749."""
+
+import sys
+from dataclasses import field
+from typing import Annotated
+
+import pytest
+from annotated_types import MaxLen
+
+from pydantic import BaseModel, Field, ValidationError
+from pydantic.dataclasses import dataclass
+
+pytestmark = pytest.mark.skipif(
+ sys.version_info < (3, 14), reason='Requires deferred evaluation of annotations introduced in Python 3.14'
+)
+
+
+def test_deferred_annotations_model() -> None:
+ class Model(BaseModel):
+ a: Int
+ b: Str = 'a'
+
+ Int = int
+ Str = str
+
+ inst = Model(a='1', b=b'test')
+ assert inst.a == 1
+ assert inst.b == 'test'
+
+
+@pytest.mark.xfail(
+ reason=(
+ 'When rebuilding model fields, we individually re-evaluate all fields (using `_eval_type()`) '
+ "and as such we don't benefit from PEP 649's capabilities."
+ ),
+)
+def test_deferred_annotations_nested_model() -> None:
+ def outer():
+ def inner():
+ class Model(BaseModel):
+ ann: Annotated[List[Dict[str, str]], MaxLen(1)]
+
+ Dict = dict
+
+ return Model
+
+ List = list
+
+ Model = inner()
+
+ return Model
+
+ Model = outer()
+
+ with pytest.raises(ValidationError) as exc_info:
+ Model(ann=[{'a': 'b'}, {'c': 'd'}])
+
+ assert exc_info.value.errors()[0]['type'] == 'too_long'
+
+
+def test_deferred_annotations_pydantic_dataclass() -> None:
+ @dataclass
+ class A:
+ a: Int = field(default=1)
+
+ Int = int
+
+ assert A(a='1').a == 1
+
+
+@pytest.mark.xfail(
+ reason="To support Pydantic's `Field()` function in dataclasses, we directly write to `__annotations__`"
+)
+def test_deferred_annotations_pydantic_dataclass_pydantic_field() -> None:
+ @dataclass
+ class A:
+ a: Int = Field(default=1)
+
+ Int = int
+
+ assert A(a='1').a == 1
Index: pydantic-2.11.9/tests/test_forward_ref.py
===================================================================
--- pydantic-2.11.9.orig/tests/test_forward_ref.py
+++ pydantic-2.11.9/tests/test_forward_ref.py
@@ -74,21 +74,6 @@ def test_forward_ref_auto_update_no_mode
assert f.model_dump() == {'a': {'b': {'a': {'b': {'a': None}}}}}
-def test_forward_ref_one_of_fields_not_defined(create_module):
- @create_module
- def module():
- from pydantic import BaseModel
-
- class Foo(BaseModel):
- foo: 'Foo'
- bar: 'Bar'
-
- assert {k: repr(v) for k, v in module.Foo.model_fields.items()} == {
- 'foo': 'FieldInfo(annotation=Foo, required=True)',
- 'bar': "FieldInfo(annotation=ForwardRef('Bar'), required=True)",
- }
-
-
def test_basic_forward_ref(create_module):
@create_module
def module():
Index: pydantic-2.11.9/tests/test_model_signature.py
===================================================================
--- pydantic-2.11.9.orig/tests/test_model_signature.py
+++ pydantic-2.11.9/tests/test_model_signature.py
@@ -184,7 +184,7 @@ def test_annotated_field():
assert typing_objects.is_annotated(get_origin(sig.parameters['foo'].annotation))
-@pytest.mark.skipif(sys.version_info < (3, 10), reason='repr different on older versions')
+@pytest.mark.skipif(sys.version_info < (3, 10), sys.version_info >= (3, 14), reason='repr different on older versions')
def test_annotated_optional_field():
from annotated_types import Gt
Index: pydantic-2.11.9/tests/test_pickle.py
===================================================================
--- pydantic-2.11.9.orig/tests/test_pickle.py
+++ pydantic-2.11.9/tests/test_pickle.py
@@ -1,6 +1,7 @@
import dataclasses
import gc
import pickle
+import sys
from typing import Optional
import pytest
@@ -17,6 +18,11 @@ except ImportError:
pytestmark = pytest.mark.skipif(cloudpickle is None, reason='cloudpickle is not installed')
+cloudpickle_xfail = pytest.mark.xfail(
+ condition=sys.version_info >= (3, 14),
+ reason='Cloudpickle issue: https://github.com/cloudpipe/cloudpickle/issues/572',
+)
+
class IntWrapper:
def __init__(self, v: int):
@@ -88,7 +94,7 @@ def model_factory() -> type:
(ImportableModel, False),
(ImportableModel, True),
# Locally-defined model can only be pickled with cloudpickle.
- (model_factory(), True),
+ pytest.param(model_factory(), True, marks=cloudpickle_xfail),
],
)
def test_pickle_model(model_type: type, use_cloudpickle: bool):
@@ -133,7 +139,7 @@ def nested_model_factory() -> type:
(ImportableNestedModel, False),
(ImportableNestedModel, True),
# Locally-defined model can only be pickled with cloudpickle.
- (nested_model_factory(), True),
+ pytest.param(nested_model_factory(), True, marks=cloudpickle_xfail),
],
)
def test_pickle_nested_model(model_type: type, use_cloudpickle: bool):
@@ -264,7 +270,7 @@ def nested_dataclass_model_factory() ->
(ImportableNestedDataclassModel, False),
(ImportableNestedDataclassModel, True),
# Locally-defined model can only be pickled with cloudpickle.
- (nested_dataclass_model_factory(), True),
+ pytest.param(nested_dataclass_model_factory(), True, marks=cloudpickle_xfail),
],
)
def test_pickle_dataclass_nested_in_model(model_type: type, use_cloudpickle: bool):
Index: pydantic-2.11.9/tests/test_v1.py
===================================================================
--- pydantic-2.11.9.orig/tests/test_v1.py
+++ pydantic-2.11.9/tests/test_v1.py
@@ -1,3 +1,4 @@
+import sys
import warnings
import pytest
@@ -14,6 +15,7 @@ def test_version():
assert V1_VERSION != VERSION
+@pytest.mark.skipif(sys.version_info >= (3, 14), reason='Python 3.14+ not supported')
@pytest.mark.thread_unsafe(reason='Mutates the value')
def test_root_validator():
class Model(V1BaseModel):