Skip to content

Commit 10af6a8

Browse files
authored
Add experimental support for free threading (pydantic#11516)
1 parent bff7477 commit 10af6a8

22 files changed

+185
-49
lines changed

.github/workflows/ci.yml

+7-2
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ jobs:
9090
fail-fast: false
9191
matrix:
9292
os: [ubuntu-latest, macos-13, macos-latest, windows-latest]
93-
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
93+
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.13t']
9494
include:
9595
# no pydantic-core binaries for pypy on windows, so tests take absolute ages
9696
# macos tests with pypy take ages (>10mins) since pypy is very slow
@@ -136,15 +136,20 @@ jobs:
136136
- name: Test without email-validator
137137
# speed up by skipping this step on pypy
138138
if: ${{ !startsWith(matrix.python-version, 'pypy') }}
139-
run: make test
139+
run: make test NUM_THREADS=${{ endsWith(matrix.python-version, 't') && '4' || '1' }}
140+
# Free threaded is flaky, allow failures for now:
141+
continue-on-error: ${{ endsWith(matrix.python-version, 't') }}
140142
env:
141143
COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-without-deps
142144
CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}-without-deps
143145

144146
- name: Install extra dependencies
147+
# Skip free threaded, we can't install memray
148+
if: ${{ !endsWith(matrix.python-version, 't') }}
145149
run: uv sync --group testing-extra --all-extras
146150

147151
- name: Test with all extra dependencies
152+
if: ${{ !endsWith(matrix.python-version, 't') }}
148153
run: make test
149154
env:
150155
COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-with-deps

Makefile

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# .DEFAULT_GOAL := all
22
sources = pydantic tests docs/plugins
3+
NUM_THREADS?=1
34

45
.PHONY: .uv ## Check that uv is installed
56
.uv:
@@ -62,7 +63,7 @@ test-typechecking-mypy: .uv
6263

6364
.PHONY: test ## Run all tests, skipping the type-checker integration tests
6465
test: .uv
65-
uv run coverage run -m pytest --durations=10
66+
uv run coverage run -m pytest --durations=10 --parallel-threads $(NUM_THREADS)
6667

6768
.PHONY: benchmark ## Run all benchmarks
6869
benchmark: .uv

pydantic/_internal/_generics.py

+22-6
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def __delitem__(self, key: KT) -> None:
9494
# and discover later on that we need to re-add all this infrastructure...
9595
# _GENERIC_TYPES_CACHE = DeepChainMap(GenericTypesCache(), LimitedDict())
9696

97-
_GENERIC_TYPES_CACHE = GenericTypesCache()
97+
_GENERIC_TYPES_CACHE: ContextVar[GenericTypesCache | None] = ContextVar('_GENERIC_TYPES_CACHE', default=None)
9898

9999

100100
class PydanticGenericMetadata(typing_extensions.TypedDict):
@@ -453,14 +453,24 @@ def get_cached_generic_type_early(parent: type[BaseModel], typevar_values: Any)
453453
during validation, I think it is worthwhile to ensure that types that are functionally equivalent are actually
454454
equal.
455455
"""
456-
return _GENERIC_TYPES_CACHE.get(_early_cache_key(parent, typevar_values))
456+
generic_types_cache = _GENERIC_TYPES_CACHE.get()
457+
if generic_types_cache is None:
458+
generic_types_cache = GenericTypesCache()
459+
_GENERIC_TYPES_CACHE.set(generic_types_cache)
460+
return generic_types_cache.get(_early_cache_key(parent, typevar_values))
457461

458462

459463
def get_cached_generic_type_late(
460464
parent: type[BaseModel], typevar_values: Any, origin: type[BaseModel], args: tuple[Any, ...]
461465
) -> type[BaseModel] | None:
462466
"""See the docstring of `get_cached_generic_type_early` for more information about the two-stage cache lookup."""
463-
cached = _GENERIC_TYPES_CACHE.get(_late_cache_key(origin, args, typevar_values))
467+
generic_types_cache = _GENERIC_TYPES_CACHE.get()
468+
if (
469+
generic_types_cache is None
470+
): # pragma: no cover (early cache is guaranteed to run first and initialize the cache)
471+
generic_types_cache = GenericTypesCache()
472+
_GENERIC_TYPES_CACHE.set(generic_types_cache)
473+
cached = generic_types_cache.get(_late_cache_key(origin, args, typevar_values))
464474
if cached is not None:
465475
set_cached_generic_type(parent, typevar_values, cached, origin, args)
466476
return cached
@@ -476,11 +486,17 @@ def set_cached_generic_type(
476486
"""See the docstring of `get_cached_generic_type_early` for more information about why items are cached with
477487
two different keys.
478488
"""
479-
_GENERIC_TYPES_CACHE[_early_cache_key(parent, typevar_values)] = type_
489+
generic_types_cache = _GENERIC_TYPES_CACHE.get()
490+
if (
491+
generic_types_cache is None
492+
): # pragma: no cover (cache lookup is guaranteed to run first and initialize the cache)
493+
generic_types_cache = GenericTypesCache()
494+
_GENERIC_TYPES_CACHE.set(generic_types_cache)
495+
generic_types_cache[_early_cache_key(parent, typevar_values)] = type_
480496
if len(typevar_values) == 1:
481-
_GENERIC_TYPES_CACHE[_early_cache_key(parent, typevar_values[0])] = type_
497+
generic_types_cache[_early_cache_key(parent, typevar_values[0])] = type_
482498
if origin and args:
483-
_GENERIC_TYPES_CACHE[_late_cache_key(origin, args, typevar_values)] = type_
499+
generic_types_cache[_late_cache_key(origin, args, typevar_values)] = type_
484500

485501

486502
def _union_orderings_key(typevar_values: Any) -> Any:

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ dev = [
7878
'faker',
7979
'pytest-benchmark',
8080
'pytest-codspeed',
81-
'pytest-memray; platform_python_implementation == "CPython" and platform_system != "Windows"',
81+
'pytest-run-parallel>=0.3.1',
8282
'packaging',
8383
'jsonschema',
8484
]
@@ -114,6 +114,7 @@ testing-extra = [
114114
'devtools',
115115
# used in docs tests
116116
'sqlalchemy',
117+
'pytest-memray; platform_python_implementation == "CPython" and platform_system != "Windows"',
117118
]
118119
typechecking = [
119120
'mypy',

tests/conftest.py

+22
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import pytest
1717
from _pytest.assertion.rewrite import AssertionRewritingHook
18+
from _pytest.nodes import Item
1819
from jsonschema import Draft202012Validator, SchemaError
1920

2021
from pydantic._internal._generate_schema import GenerateSchema
@@ -178,3 +179,24 @@ def generate(*args: Any, **kwargs: Any) -> Any:
178179
return json_schema
179180

180181
monkeypatch.setattr(GenerateJsonSchema, 'generate', generate)
182+
183+
184+
_thread_unsafe_fixtures = (
185+
'generate_schema_calls', # Monkeypatches Pydantic code
186+
'benchmark', # Fixture can't be reused
187+
'tmp_path', # Duplicate paths
188+
'tmpdir', # Duplicate dirs
189+
'copy_method', # Uses `pytest.warns()`
190+
'reset_plugins', # Monkeypatching
191+
)
192+
193+
194+
# Note: it is important to add the marker in the `pytest_itemcollected` hook.
195+
# `pytest-run-parallel` also implements this hook (and ours is running before),
196+
# and this wouldn't work if we were to add the "thread_unsafe" marker in say
197+
# `pytest_collection_modifyitems` (which is the last collection hook to be run).
198+
def pytest_itemcollected(item: Item) -> None:
199+
"""Mark tests as thread unsafe if they make use of fixtures that doesn't play well across threads."""
200+
fixtures: tuple[str, ...] = getattr(item, 'fixturenames', ())
201+
if any(fixture in fixtures for fixture in _thread_unsafe_fixtures):
202+
item.add_marker('thread_unsafe')

tests/test_aliases.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,13 @@ class Model(BaseModel):
388388
[False, True, 'bar', does_not_raise()],
389389
[False, True, 'bar_', does_not_raise()],
390390
[False, False, 'bar', does_not_raise()],
391-
[False, False, 'bar_', pytest.raises(ValueError)],
391+
pytest.param(
392+
False,
393+
False,
394+
'bar_',
395+
pytest.raises(ValueError),
396+
marks=pytest.mark.thread_unsafe(reason='`pytest.raises()` is thread unsafe'),
397+
),
392398
[True, True, 'bar', does_not_raise()],
393399
[True, True, 'bar_', does_not_raise()],
394400
[True, False, 'bar', does_not_raise()],

tests/test_annotated.py

+7
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
NO_VALUE = object()
2727

2828

29+
@pytest.mark.thread_unsafe(
30+
reason=(
31+
'The `FieldInfo.from_annotated_attribute()` implementation directly mutates the assigned value, '
32+
'if it is a `Field()`. https://github.com/pydantic/pydantic/issues/11122 tracks this issue'
33+
)
34+
)
2935
@pytest.mark.parametrize(
3036
'hint_fn,value,expected_repr',
3137
[
@@ -113,6 +119,7 @@ class M(BaseModel):
113119
assert metadata in M.__annotations__['x'].__metadata__, 'Annotated type is recorded'
114120

115121

122+
@pytest.mark.thread_unsafe(reason='`pytest.raises()` is thread unsafe')
116123
@pytest.mark.parametrize(
117124
['hint_fn', 'value', 'empty_init_ctx'],
118125
[

tests/test_config.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,13 @@ class ModelNoArbitraryTypes(BaseModel):
305305
[False, True, 'bar', does_not_raise()],
306306
[False, True, 'bar_', does_not_raise()],
307307
[False, False, 'bar', does_not_raise()],
308-
[False, False, 'bar_', pytest.raises(ValueError)],
308+
pytest.param(
309+
False,
310+
False,
311+
'bar_',
312+
pytest.raises(ValueError),
313+
marks=pytest.mark.thread_unsafe(reason='`pytest.raises()` is thread unsafe'),
314+
),
309315
[True, True, 'bar', does_not_raise()],
310316
[True, True, 'bar_', does_not_raise()],
311317
[True, False, 'bar', does_not_raise()],
@@ -512,6 +518,7 @@ class Child(Mixin, Parent):
512518
assert Child.model_config.get('use_enum_values') is True
513519

514520

521+
@pytest.mark.thread_unsafe(reason='Flaky')
515522
def test_config_wrapper_match():
516523
localns = {
517524
'_GenerateSchema': GenerateSchema,

tests/test_construction.py

+7
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ def test_deep_copy(ModelTwo, copy_method):
175175
assert m._foo_ is not m2._foo_
176176

177177

178+
@pytest.mark.thread_unsafe(reason='`pytest.warns()` is thread unsafe')
178179
def test_copy_exclude(ModelTwo):
179180
m = ModelTwo(a=24, d=Model(a='12'))
180181
m2 = deprecated_copy(m, exclude={'b'})
@@ -191,6 +192,7 @@ def test_copy_exclude(ModelTwo):
191192
assert m != m2
192193

193194

195+
@pytest.mark.thread_unsafe(reason='`pytest.warns()` is thread unsafe')
194196
def test_copy_include(ModelTwo):
195197
m = ModelTwo(a=24, d=Model(a='12'))
196198
m2 = deprecated_copy(m, include={'a'})
@@ -202,6 +204,7 @@ def test_copy_include(ModelTwo):
202204
assert m != m2
203205

204206

207+
@pytest.mark.thread_unsafe(reason='`pytest.warns()` is thread unsafe')
205208
def test_copy_include_exclude(ModelTwo):
206209
m = ModelTwo(a=24, d=Model(a='12'))
207210
m2 = deprecated_copy(m, include={'a', 'b', 'c'}, exclude={'c'})
@@ -210,6 +213,7 @@ def test_copy_include_exclude(ModelTwo):
210213
assert set(m2.model_dump().keys()) == {'a', 'b'}
211214

212215

216+
@pytest.mark.thread_unsafe(reason='`pytest.warns()` is thread unsafe')
213217
def test_copy_advanced_exclude():
214218
class SubSubModel(BaseModel):
215219
a: str
@@ -233,6 +237,7 @@ class Model(BaseModel):
233237
assert m2.model_dump() == {'f': {'c': 'foo'}}
234238

235239

240+
@pytest.mark.thread_unsafe(reason='`pytest.warns()` is thread unsafe')
236241
def test_copy_advanced_include():
237242
class SubSubModel(BaseModel):
238243
a: str
@@ -256,6 +261,7 @@ class Model(BaseModel):
256261
assert m2.model_dump() == {'e': 'e', 'f': {'d': [{'a': 'c', 'b': 'e'}]}}
257262

258263

264+
@pytest.mark.thread_unsafe(reason='`pytest.warns()` is thread unsafe')
259265
def test_copy_advanced_include_exclude():
260266
class SubSubModel(BaseModel):
261267
a: str
@@ -484,6 +490,7 @@ class Model(BaseModel):
484490
assert m.bar == 'Baz'
485491

486492

493+
@pytest.mark.thread_unsafe(reason='`pytest.warns()` is thread unsafe')
487494
def test_copy_with_excluded_fields():
488495
class User(BaseModel):
489496
name: str

tests/test_deprecated.py

+4
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
from pydantic.json_schema import JsonSchemaValue
3333
from pydantic.type_adapter import TypeAdapter
3434

35+
# `pytest.warns/raises()` is thread unsafe. As these tests are meant to be
36+
# removed in V3, we just mark all tests as thread unsafe
37+
pytestmark = pytest.mark.thread_unsafe
38+
3539

3640
def deprecated_from_orm(model_type: type[BaseModel], obj: Any) -> Any:
3741
with pytest.warns(

tests/test_deprecated_validate_arguments.py

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
from pydantic.deprecated.decorator import validate_arguments as validate_arguments_deprecated
1212
from pydantic.errors import PydanticUserError
1313

14+
# `pytest.warns/raises()` is thread unsafe. As these tests are meant to be
15+
# removed in V3, we just mark all tests as thread unsafe
16+
pytestmark = pytest.mark.thread_unsafe
17+
1418

1519
def validate_arguments(*args, **kwargs):
1620
with pytest.warns(

tests/test_discriminated_union.py

+2
Original file line numberDiff line numberDiff line change
@@ -1655,6 +1655,7 @@ class PetModelAnnotatedWithField(BaseModel):
16551655
]
16561656

16571657

1658+
@pytest.mark.thread_unsafe(reason='`pytest.raises()` is thread unsafe')
16581659
def test_callable_discriminated_union_recursive():
16591660
# Demonstrate that the errors are very verbose without a callable discriminator:
16601661
class Model(BaseModel):
@@ -2149,6 +2150,7 @@ class Yard(BaseModel):
21492150
assert str(yard_dict['pet']['type']) == 'dog'
21502151

21512152

2153+
@pytest.mark.thread_unsafe(reason='Passes on multithreaded. This needs to be investigated further.')
21522154
@pytest.mark.xfail(reason='Waiting for union serialization fixes via https://github.com/pydantic/pydantic/issues/9688.')
21532155
def test_discriminated_union_serializer() -> None:
21542156
"""Reported via https://github.com/pydantic/pydantic/issues/9590."""

tests/test_docs.py

+3
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ def run_example(example: CodeExample, eval_example: EvalExample, mocker: Any) ->
142142
group_globals.set(group_name, d2)
143143

144144

145+
@pytest.mark.thread_unsafe
145146
@pytest.mark.filterwarnings('ignore:(parse_obj_as|schema_json_of|schema_of) is deprecated.*:DeprecationWarning')
146147
@pytest.mark.skipif(bool(skip_reason), reason=skip_reason or 'not skipping')
147148
@pytest.mark.parametrize('example', find_examples(str(SOURCES_ROOT), skip=sys.platform == 'win32'), ids=str)
@@ -165,6 +166,7 @@ def set_cwd():
165166
os.chdir(cwd)
166167

167168

169+
@pytest.mark.thread_unsafe
168170
@pytest.mark.filterwarnings('ignore:(parse_obj_as|schema_json_of|schema_of) is deprecated.*:DeprecationWarning')
169171
@pytest.mark.filterwarnings('ignore::pydantic.warnings.PydanticExperimentalWarning')
170172
@pytest.mark.skipif(bool(skip_reason), reason=skip_reason or 'not skipping')
@@ -184,6 +186,7 @@ def test_docs_examples(example: CodeExample, eval_example: EvalExample, tmp_path
184186
run_example(example, eval_example, mocker)
185187

186188

189+
@pytest.mark.thread_unsafe
187190
@pytest.mark.skipif(bool(skip_reason), reason=skip_reason or 'not skipping')
188191
@pytest.mark.skipif(sys.version_info >= (3, 13), reason='python-devtools does not yet support python 3.13')
189192
@pytest.mark.parametrize(

tests/test_exports.py

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def test_public_api_dynamic_imports(attr_name, value):
2828
assert isinstance(imported_object, object)
2929

3030

31+
@pytest.mark.thread_unsafe
3132
@pytest.mark.filterwarnings('ignore::DeprecationWarning')
3233
@pytest.mark.filterwarnings('ignore::pydantic.warnings.PydanticExperimentalWarning')
3334
def test_public_internal():

0 commit comments

Comments
 (0)