Skip to content
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
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
([#753](https://github.com/python-attrs/cattrs/pull/753))
- {meth}`BaseConverter.register_structure_hook_factory` and {meth}`BaseConverter.register_unstructure_hook_factory` now properly return the factory when used as decorators.
([#724](https://github.com/python-attrs/cattrs/pull/724))
- The {mod}`msgspec <cattrs.preconf.msgspec>` preconf converter now properly handles recursive classes on Python 3.14+.
([#757](https://github.com/python-attrs/cattrs/pull/757))

## 26.1.0 (2026-02-18)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ bson = [
"pymongo>=4.4.0",
]
msgspec = [
"msgspec>=0.19.0; implementation_name == \"cpython\"",
"msgspec>=0.21.1; implementation_name == \"cpython\"",
]
tomllib = [
"tomli>=1.1.0; python_version < '3.11'",
Expand Down
43 changes: 31 additions & 12 deletions src/cattrs/preconf/msgspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from datetime import date, datetime
from enum import Enum
from functools import partial
from threading import local
from typing import Any, TypeVar, Union, get_type_hints

from attrs import has as attrs_has
Expand All @@ -33,10 +34,12 @@
from ..strategies import configure_union_passthrough
from . import literals_with_enums_unstructure_factory, wrap

T = TypeVar("T")

__all__ = ["MsgspecJsonConverter", "configure_converter", "make_converter"]

T = TypeVar("T")
_already_probing = local()
"""Used to detect and handle recursive data structures."""


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


def mapping_unstructure_factory(type, converter: BaseConverter) -> UnstructureHook:
def mapping_unstructure_factory(type, converter: Converter) -> UnstructureHook:
"""The msgspec unstructure hook factory for mappings."""
if is_bare(type):
key_arg = Any
Expand Down Expand Up @@ -176,20 +179,36 @@ def msgspec_attrs_unstructure_factory(
private attributes, making us do the work.
"""
origin = get_origin(type)
attribs = fields(origin or type)
base = origin or type
attribs = fields(base)
if attrs_has(type) and any(isinstance(a.type, str) for a in attribs):
resolve_types(type)
attribs = fields(origin or type)
attribs = fields(base)

if msgspec_skips_private and any(
attr.name.startswith("_")
or (
if msgspec_skips_private and any(attr.name.startswith("_") for attr in attribs):
# Private attributes we have to do ourselves.
return converter.gen_unstructure_attrs_fromdict(type)

try:
working_set = _already_probing.working_set
if base in working_set:
return to_builtins
except AttributeError:
working_set = set()
_already_probing.working_set = working_set

try:
working_set.add(base)
if any(
converter.get_unstructure_hook(attr.type, cache_result=False)
not in (identity, to_builtins)
)
for attr in attribs
):
return converter.gen_unstructure_attrs_fromdict(type)
for attr in attribs
):
return converter.gen_unstructure_attrs_fromdict(type)
finally:
working_set.remove(base)
if not working_set:
del _already_probing.working_set

return to_builtins

Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def converter_cls(request):
collect_ignore_glob = []
if sys.version_info < (3, 14):
collect_ignore_glob.append("test_gen_dict_649.py")
collect_ignore_glob.append("**/test_msgspec_314_cpython.py")
if sys.version_info < (3, 12):
collect_ignore_glob.append("*_695.py")
if platform.python_implementation() == "PyPy":
Expand Down
23 changes: 23 additions & 0 deletions tests/preconf/test_msgspec_314_cpython.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Tests for msgspec functionality on Python 3.14."""

from attrs import define
from msgspec import to_builtins

from cattrs.preconf.msgspec import make_converter


@define
class RecursiveAttrs:
children: list[RecursiveAttrs] # noqa: F821


def test_unstructure_recursive_attrs_class_with_self_list():
"""Unstructuring recursive data structures under 3.14 works."""
converter = make_converter()

inst = RecursiveAttrs([RecursiveAttrs([])])
raw = {"children": [{"children": []}]}

assert converter.get_unstructure_hook(RecursiveAttrs) is to_builtins
assert converter.unstructure(inst) == raw
assert converter.structure(raw, RecursiveAttrs) == inst
Loading
Loading