diff --git a/HISTORY.md b/HISTORY.md index a4464ea6..e8e631d9 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ 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+. 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}