Skip to content

Commit 3d07082

Browse files
committed
Fix msgspec optimizations on 3.14
1 parent c8608b8 commit 3d07082

6 files changed

Lines changed: 111 additions & 46 deletions

File tree

HISTORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
3232
([#753](https://github.com/python-attrs/cattrs/pull/753))
3333
- {meth}`BaseConverter.register_structure_hook_factory` and {meth}`BaseConverter.register_unstructure_hook_factory` now properly return the factory when used as decorators.
3434
([#724](https://github.com/python-attrs/cattrs/pull/724))
35+
- The {mod}`msgspec <cattrs.preconf.msgspec>` preconf converter now properly handles recursive classes on Python 3.14+.
36+
([#757](https://github.com/python-attrs/cattrs/pull/757))
3537

3638
## 26.1.0 (2026-02-18)
3739

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ bson = [
101101
"pymongo>=4.4.0",
102102
]
103103
msgspec = [
104-
"msgspec>=0.19.0; implementation_name == \"cpython\"",
104+
"msgspec>=0.21.1; implementation_name == \"cpython\"",
105105
]
106106
tomllib = [
107107
"tomli>=1.1.0; python_version < '3.11'",

src/cattrs/preconf/msgspec.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from datetime import date, datetime
99
from enum import Enum
1010
from functools import partial
11+
from threading import local
1112
from typing import Any, TypeVar, Union, get_type_hints
1213

1314
from attrs import has as attrs_has
@@ -33,10 +34,12 @@
3334
from ..strategies import configure_union_passthrough
3435
from . import literals_with_enums_unstructure_factory, wrap
3536

36-
T = TypeVar("T")
37-
3837
__all__ = ["MsgspecJsonConverter", "configure_converter", "make_converter"]
3938

39+
T = TypeVar("T")
40+
_already_probing = local()
41+
"""Used to detect and handle recursive data structures."""
42+
4043

4144
class MsgspecJsonConverter(Converter):
4245
"""A converter specialized for the _msgspec_ library."""
@@ -141,7 +144,7 @@ def seq_unstructure_factory(type, converter: Converter) -> UnstructureHook:
141144
return converter.gen_unstructure_iterable(type)
142145

143146

144-
def mapping_unstructure_factory(type, converter: BaseConverter) -> UnstructureHook:
147+
def mapping_unstructure_factory(type, converter: Converter) -> UnstructureHook:
145148
"""The msgspec unstructure hook factory for mappings."""
146149
if is_bare(type):
147150
key_arg = Any
@@ -176,20 +179,36 @@ def msgspec_attrs_unstructure_factory(
176179
private attributes, making us do the work.
177180
"""
178181
origin = get_origin(type)
179-
attribs = fields(origin or type)
182+
base = origin or type
183+
attribs = fields(base)
180184
if attrs_has(type) and any(isinstance(a.type, str) for a in attribs):
181185
resolve_types(type)
182-
attribs = fields(origin or type)
186+
attribs = fields(base)
183187

184-
if msgspec_skips_private and any(
185-
attr.name.startswith("_")
186-
or (
188+
if msgspec_skips_private and any(attr.name.startswith("_") for attr in attribs):
189+
# Private attributes we have to do ourselves.
190+
return converter.gen_unstructure_attrs_fromdict(type)
191+
192+
try:
193+
working_set = _already_probing.working_set
194+
if base in working_set:
195+
return to_builtins
196+
except AttributeError:
197+
working_set = set()
198+
_already_probing.working_set = working_set
199+
200+
try:
201+
working_set.add(base)
202+
if any(
187203
converter.get_unstructure_hook(attr.type, cache_result=False)
188204
not in (identity, to_builtins)
189-
)
190-
for attr in attribs
191-
):
192-
return converter.gen_unstructure_attrs_fromdict(type)
205+
for attr in attribs
206+
):
207+
return converter.gen_unstructure_attrs_fromdict(type)
208+
finally:
209+
working_set.remove(base)
210+
if not working_set:
211+
del _already_probing.working_set
193212

194213
return to_builtins
195214

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def converter_cls(request):
3333
collect_ignore_glob = []
3434
if sys.version_info < (3, 14):
3535
collect_ignore_glob.append("test_gen_dict_649.py")
36+
collect_ignore_glob.append("**/test_msgspec_314_cpython.py")
3637
if sys.version_info < (3, 12):
3738
collect_ignore_glob.append("*_695.py")
3839
if platform.python_implementation() == "PyPy":
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Tests for msgspec functionality on Python 3.14."""
2+
3+
from attrs import define
4+
from msgspec import to_builtins
5+
6+
from cattrs.preconf.msgspec import make_converter
7+
8+
9+
@define
10+
class RecursiveAttrs:
11+
children: list[RecursiveAttrs] # noqa: F821
12+
13+
14+
def test_unstructure_recursive_attrs_class_with_self_list():
15+
"""Unstructuring recursive data structures under 3.14 works."""
16+
converter = make_converter()
17+
18+
inst = RecursiveAttrs([RecursiveAttrs([])])
19+
raw = {"children": [{"children": []}]}
20+
21+
assert converter.get_unstructure_hook(RecursiveAttrs) is to_builtins
22+
assert converter.unstructure(inst) == raw
23+
assert converter.structure(raw, RecursiveAttrs) == inst

0 commit comments

Comments
 (0)