From 412bccc7d95fb5310c4f1be602f9c6f3913e6b52 Mon Sep 17 00:00:00 2001 From: digit50 <93092234+digit50@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:41:03 +0300 Subject: [PATCH 1/2] Use cattrs hooks for nested recursive classes in msgspec The msgspec attrs hook factory generated unstructuring hooks while its recursion probe was still active, so handlers for fields leading back to a class being probed were baked in as msgspec.to_builtins. Hook generation is now deferred to the first call whenever a probe is active, so every level of a recursive class goes through the same cattrs hook. --- HISTORY.md | 1 + src/cattrs/preconf/msgspec.py | 29 ++++++++++++++--- tests/preconf/test_msgspec_cpython.py | 47 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index a4464ea6..d789de5e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- The msgspec converter now applies the same unstructuring hook to nested instances of recursive _attrs_ classes, so registered hooks and private attributes are no longer skipped below the top level. - Dict unstructuring hook factories now inherit the converter's `omit_if_default` setting when `_cattrs_omit_if_default` is omitted. Explicit `True` or `False` values and per-attribute overrides retain their precedence. ([#238](https://github.com/python-attrs/cattrs/issues/238)) - Support the [`frozendict`](https://docs.python.org/3.15/builtins/stdtypes.html#frozendict) built-in on Python 3.15+. diff --git a/src/cattrs/preconf/msgspec.py b/src/cattrs/preconf/msgspec.py index 1c73d28f..0623fda6 100644 --- a/src/cattrs/preconf/msgspec.py +++ b/src/cattrs/preconf/msgspec.py @@ -202,7 +202,7 @@ def msgspec_attrs_unstructure_factory( 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) + return _gen_attrs_hook(type, converter) try: working_set = _already_probing.working_set @@ -214,18 +214,37 @@ def msgspec_attrs_unstructure_factory( try: working_set.add(base) - if any( + needs_hook = 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) + ) finally: working_set.remove(base) if not working_set: del _already_probing.working_set - return to_builtins + return _gen_attrs_hook(type, converter) if needs_hook else to_builtins + + +def _gen_attrs_hook(type: Any, converter: Converter) -> UnstructureHook: + """Generate the hook, deferring generation to the first call during a probe. + + Hooks generated during a probe would bake in the `to_builtins` answers + the probe gives for classes it is still visiting. + """ + if not hasattr(_already_probing, "working_set"): + return converter.gen_unstructure_attrs_fromdict(type) + + hook = None + + def unstructure_lazily(obj): + nonlocal hook + if hook is None: + hook = converter.gen_unstructure_attrs_fromdict(type) + return hook(obj) + + return unstructure_lazily def namedtuple_unstructure_factory( diff --git a/tests/preconf/test_msgspec_cpython.py b/tests/preconf/test_msgspec_cpython.py index 1576c039..eb683946 100644 --- a/tests/preconf/test_msgspec_cpython.py +++ b/tests/preconf/test_msgspec_cpython.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime, timezone from enum import Enum from typing import ( Any, @@ -81,6 +82,28 @@ class E(Enum): TEST = "test" +@define +class WithNC: + n: NC + + +@define +class Event: + at: datetime + children: list[Event] + + +@define +class MutualA: + bs: list[MutualB] + + +@define +class MutualB: + _secret: int + as_: list[MutualA] + + @fixture def converter() -> Conv: return make_converter() @@ -185,3 +208,27 @@ def test_simple_classes(cls_and_vals): rebuilt_json = json.loads(json.dumps(inst), cl) assert rebuilt_msgspec == rebuilt_json + + +def test_unstructure_recursive_hooks(converter: Conv): + """Nested instances of a recursive class use the same hook as the top level.""" + converter.register_unstructure_hook(datetime, lambda d: int(d.timestamp())) + ts = datetime(2026, 1, 1, tzinfo=timezone.utc) + + assert converter.unstructure(Event(ts, [Event(ts, [])])) == { + "at": 1767225600, + "children": [{"at": 1767225600, "children": []}], + } + + +def test_unstructure_mutually_recursive_private(converter: Conv): + """Nested instances of mutually recursive classes keep private attributes.""" + assert converter.unstructure( + MutualA([MutualB(1, [MutualA([MutualB(2, [])])])]) + ) == {"bs": [{"_secret": 1, "as_": [{"bs": [{"_secret": 2, "as_": []}]}]}]} + + +def test_unstructure_private_in_namedtuple(converter: Conv): + """A private class reached through a namedtuple during a probe gets a working hook.""" + assert converter.unstructure(WithNC(NC(C(1)))) == {"n": ({"_a": 1},)} + assert converter.unstructure(C(2)) == {"_a": 2} From a0ff0f1408f4dccf42465aa49966871f8c291273 Mon Sep 17 00:00:00 2001 From: digit50 <93092234+digit50@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:48:32 +0300 Subject: [PATCH 2/2] Link the changelog entry to the pull request --- HISTORY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/HISTORY.md b/HISTORY.md index d789de5e..e8e631d9 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -14,6 +14,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT - The msgspec converter now applies the same unstructuring hook to nested instances of recursive _attrs_ classes, so registered hooks and private attributes are no longer skipped below the top level. + ([#803](https://github.com/python-attrs/cattrs/pull/803)) - Dict unstructuring hook factories now inherit the converter's `omit_if_default` setting when `_cattrs_omit_if_default` is omitted. Explicit `True` or `False` values and per-attribute overrides retain their precedence. ([#238](https://github.com/python-attrs/cattrs/issues/238)) - Support the [`frozendict`](https://docs.python.org/3.15/builtins/stdtypes.html#frozendict) built-in on Python 3.15+.