Skip to content

Add support for Python 3.14 #1714

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,6 @@ jobs:

- uses: codecov/test-results-action@v1

# See https://github.com/PyO3/pyo3/discussions/2781
# tests intermittently segfault with pypy and cpython 3.7 when using `coverage run ...`, hence separate job
test-python:
name: test ${{ matrix.python-version }}
strategy:
Expand All @@ -71,6 +69,8 @@ jobs:
- '3.12'
- '3.13'
- '3.13t'
- '3.14'
- '3.14t'
- 'pypy3.9'
- 'pypy3.10'

Expand Down Expand Up @@ -412,15 +412,15 @@ jobs:
- os: linux
manylinux: auto
target: armv7
interpreter: 3.9 3.10 3.11 3.12 3.13
interpreter: 3.9 3.10 3.11 3.12 3.13 3.14
- os: linux
manylinux: auto
target: ppc64le
interpreter: 3.9 3.10 3.11 3.12 3.13
interpreter: 3.9 3.10 3.11 3.12 3.13 3.14
- os: linux
manylinux: auto
target: s390x
interpreter: 3.9 3.10 3.11 3.12 3.13
interpreter: 3.9 3.10 3.11 3.12 3.13 3.14
- os: linux
manylinux: auto
target: x86_64
Expand Down Expand Up @@ -456,10 +456,10 @@ jobs:
- os: windows
target: i686
python-architecture: x86
interpreter: 3.9 3.10 3.11 3.12 3.13
interpreter: 3.9 3.10 3.11 3.12 3.13 3.14
- os: windows
target: aarch64
interpreter: 3.11 3.12 3.13
interpreter: 3.11 3.12 3.13 3.14

exclude:
# See above; disabled for now.
Expand All @@ -483,7 +483,7 @@ jobs:
with:
target: ${{ matrix.target }}
manylinux: ${{ matrix.manylinux }}
args: --release --out dist --interpreter ${{ matrix.interpreter || '3.9 3.10 3.11 3.12 3.13 pypy3.9 pypy3.10 pypy3.11' }}
args: --release --out dist --interpreter ${{ matrix.interpreter || '3.9 3.10 3.11 3.12 3.13 3.14 pypy3.9 pypy3.10 pypy3.11' }}
rust-toolchain: stable
docker-options: -e CI

Expand All @@ -504,7 +504,7 @@ jobs:
fail-fast: false
matrix:
os: [linux, windows, macos]
interpreter: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.13t']
interpreter: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.13t', '3.14', '3.14t']
include:
# standard runners with override for macos arm
- os: linux
Expand Down
25 changes: 12 additions & 13 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ rust-version = "1.75"
[dependencies]
# TODO it would be very nice to remove the "py-clone" feature as it can panic,
# but needs a bit of work to make sure it's not used in the codebase
pyo3 = { version = "0.24", features = ["generate-import-lib", "num-bigint", "py-clone"] }
pyo3 = { version = "0.25", features = ["generate-import-lib", "num-bigint", "py-clone"] }
regex = "1.11.1"
strum = { version = "0.26.3", features = ["derive"] }
strum_macros = "0.26.4"
Expand All @@ -44,7 +44,7 @@ base64 = "0.22.1"
num-bigint = "0.4.6"
num-traits = "0.2.19"
uuid = "1.16.0"
jiter = { version = "0.9.1", features = ["python"] }
jiter = { version = "0.10.0", features = ["python"] }
hex = "0.4.3"

[lib]
Expand Down Expand Up @@ -72,12 +72,12 @@ debug = true
strip = false

[dev-dependencies]
pyo3 = { version = "0.24", features = ["auto-initialize"] }
pyo3 = { version = "0.25", features = ["auto-initialize"] }

[build-dependencies]
version_check = "0.9.5"
# used where logic has to be version/distribution specific, e.g. pypy
pyo3-build-config = { version = "0.24" }
pyo3-build-config = { version = "0.25" }

[lints.clippy]
dbg_macro = "warn"
Expand Down
9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: 3.14',
'Programming Language :: Rust',
'Framework :: Pydantic',
'Intended Audience :: Developers',
Expand All @@ -34,7 +35,9 @@ classifiers = [
'Operating System :: MacOS',
'Typing :: Typed',
]
dependencies = ['typing-extensions>=4.6.0,!=4.7.0']
dependencies = [
'typing-extensions>=4.13.0',
]
dynamic = ['description', 'license', 'readme', 'version']

[project.urls]
Expand Down Expand Up @@ -65,10 +68,10 @@ testing = [
'numpy; python_version < "3.13" and implementation_name == "cpython" and platform_machine == "x86_64"',
'exceptiongroup; python_version < "3.11"',
'tzdata',
'typing_extensions',
'typing-inspection>=0.4.1',
]
linting = [{ include-group = "dev" }, 'griffe', 'pyright', 'ruff', 'mypy']
wasm = [{ include-group = "dev" }, 'typing_extensions', 'ruff']
wasm = [{ include-group = "dev" }, 'ruff']
codspeed = [
# codspeed is only run on CI, with latest version of CPython
'pytest-codspeed; python_version == "3.13" and implementation_name == "cpython"',
Expand Down
7 changes: 2 additions & 5 deletions src/py_gc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,15 @@ use std::sync::Arc;

use ahash::AHashMap;
use enum_dispatch::enum_dispatch;
use pyo3::{AsPyPointer, Py, PyTraverseError, PyVisit};
use pyo3::{Py, PyTraverseError, PyVisit};

/// Trait implemented by types which can be traversed by the Python GC.
#[enum_dispatch]
pub trait PyGcTraverse {
fn py_gc_traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError>;
}

impl<T> PyGcTraverse for Py<T>
where
Py<T>: AsPyPointer,
{
impl<T> PyGcTraverse for Py<T> {
fn py_gc_traverse(&self, visit: &PyVisit<'_>) -> Result<(), PyTraverseError> {
visit.call(self)
}
Expand Down
2 changes: 1 addition & 1 deletion src/serializers/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ where

/// detect both ellipsis and `True` to be compatible with pydantic V1
fn is_ellipsis_like(v: &Bound<'_, PyAny>) -> bool {
v.is(&v.py().Ellipsis())
v.is(v.py().Ellipsis())
|| match v.downcast::<PyBool>() {
Ok(b) => b.is_true(),
Err(_) => false,
Expand Down
7 changes: 3 additions & 4 deletions src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use pyo3::types::{PyDict, PyString};
use pyo3::{intern, FromPyObject};

use crate::input::Int;
use jiter::{cached_py_string, pystring_fast_new, StringCacheMode};
use jiter::{cached_py_string, StringCacheMode};

pub trait SchemaDict<'py> {
fn get_as<T>(&self, key: &Bound<'py, PyString>) -> PyResult<Option<T>>
Expand Down Expand Up @@ -148,11 +148,10 @@ pub fn extract_int(v: &Bound<'_, PyAny>) -> Option<Int> {

pub(crate) fn new_py_string<'py>(py: Python<'py>, s: &str, cache_str: StringCacheMode) -> Bound<'py, PyString> {
// we could use `bytecount::num_chars(s.as_bytes()) == s.len()` as orjson does, but it doesn't appear to be faster
let ascii_only = false;
if matches!(cache_str, StringCacheMode::All) {
cached_py_string(py, s, ascii_only)
cached_py_string(py, s)
} else {
pystring_fast_new(py, s, ascii_only)
PyString::new(py, s)
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/validators/dataclass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ impl Validator for DataclassArgsValidator {
let data_dict = dict.copy()?;
if let Err(err) = data_dict.del_item(field_name) {
// KeyError is fine here as the field might not be in the dict
if !err.get_type(py).is(&PyType::new::<PyKeyError>(py)) {
if !err.get_type(py).is(PyType::new::<PyKeyError>(py)) {
return Err(err.into());
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/validators/enum_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ impl<T: EnumValidateValue> Validator for EnumValidator<T> {
// https://github.com/python/cpython/blob/v3.12.2/Lib/enum.py#L1148
if enum_value.is_instance(class)? {
return Ok(enum_value.into());
} else if !enum_value.is(&py.None()) {
} else if !enum_value.is(py.None()) {
let type_error = PyTypeError::new_err(format!(
"error in {}._missing_: returned {} instead of None or a valid member",
class
Expand Down
2 changes: 1 addition & 1 deletion src/validators/model_fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ impl Validator for ModelFieldsValidator {
let data_dict = dict.copy()?;
if let Err(err) = data_dict.del_item(field_name) {
// KeyError is fine here as the field might not be in the dict
if !err.get_type(py).is(&PyType::new::<PyKeyError>(py)) {
if !err.get_type(py).is(PyType::new::<PyKeyError>(py)) {
return Err(err.into());
}
}
Expand Down
1 change: 1 addition & 0 deletions tests/emscripten_runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ await micropip.install([
'tzdata',
'file:${wheel_path}',
'typing-extensions',
'typing-inspection',
])
importlib.invalidate_caches()

Expand Down
5 changes: 3 additions & 2 deletions tests/test_garbage_collection.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import platform
import sys
from collections.abc import Iterable
from typing import Any
from weakref import WeakValueDictionary
Expand All @@ -20,7 +21,7 @@
)


@pytest.mark.xfail(is_free_threaded, reason='GC leaks on free-threaded')
@pytest.mark.xfail(is_free_threaded and sys.version_info < (3, 14), reason='GC leaks on free-threaded (<3.14)')
@pytest.mark.xfail(
condition=platform.python_implementation() == 'PyPy', reason='https://foss.heptapod.net/pypy/pypy/-/issues/3899'
)
Expand Down Expand Up @@ -48,7 +49,7 @@ class MyModel(BaseModel):
assert_gc(lambda: len(cache) == 0)


@pytest.mark.xfail(is_free_threaded, reason='GC leaks on free-threaded')
@pytest.mark.xfail(is_free_threaded and sys.version_info < (3, 14), reason='GC leaks on free-threaded (<3.14)')
@pytest.mark.xfail(
condition=platform.python_implementation() == 'PyPy', reason='https://foss.heptapod.net/pypy/pypy/-/issues/3899'
)
Expand Down
34 changes: 26 additions & 8 deletions tests/test_misc.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import copy
import pickle
import re

import pytest
from typing_extensions import get_args
from typing_extensions import ( # noqa: UP035 (https://github.com/astral-sh/ruff/pull/18476)
get_args,
get_origin,
get_type_hints,
)
from typing_inspection import typing_objects
from typing_inspection.introspection import UNKNOWN, AnnotationSource, inspect_annotation

from pydantic_core import CoreConfig, CoreSchema, CoreSchemaType, PydanticUndefined, core_schema
from pydantic_core._pydantic_core import (
Expand Down Expand Up @@ -159,13 +164,26 @@ class MyModel:


def test_core_schema_type_literal():
def get_type_value(schema):
type_ = schema.__annotations__['type']
m = re.search(r"Literal\['(.+?)']", type_.__forward_arg__)
assert m, f'Unknown schema type: {type_}'
return m.group(1)
def get_type_value(schema_typeddict) -> str:
annotation = get_type_hints(schema_typeddict, include_extras=True)['type']
inspected_ann = inspect_annotation(annotation, annotation_source=AnnotationSource.TYPED_DICT)
annotation = inspected_ann.type
assert annotation is not UNKNOWN
assert typing_objects.is_literal(get_origin(annotation)), (
f"The 'type' key of core schemas must be a Literal form, got {get_origin(annotation)}"
)
args = get_args(annotation)
assert len(args) == 1, (
f"The 'type' key of core schemas must be a Literal form with a single element, got {len(args)} elements"
)
type_ = args[0]
assert isinstance(type_, str), (
f"The 'type' key of core schemas must be a Literal form with a single string element, got element of type {type(type_)}"
)

return type_

schema_types = tuple(get_type_value(x) for x in CoreSchema.__args__)
schema_types = (get_type_value(x) for x in CoreSchema.__args__)
schema_types = tuple(dict.fromkeys(schema_types)) # remove duplicates while preserving order
if get_args(CoreSchemaType) != schema_types:
literal = ''.join(f'\n {e!r},' for e in schema_types)
Expand Down
Loading
Loading