msgspec converter skips hooks and drops private fields in nested recursion
Maintainers usually reply within 1 day
@Gabrieljr42 is already working on this.
Since Oct 16, 2026.
Assessment
- Difficulty
- 3/5
- Estimated time
- Half a day
- Newbie friendliness
- 64/100
Research direction
Start in msgspec_attrs_unstructure_factory in src/cattrs/preconf/msgspec.py (around lines 195-226), where the _already_probing.working_set guard returns to_builtins and the private-attribute early return sits. Reproduce with the two snippets in the issue, then check that nested Event.children and B._secret use the registered hook and private fields. Done when both printed outputs match the expected lines and a recursive test in tests/preconf/test_msgspec_cpython.py covers the mutually recursive case on all Python versions.
Written by the indexing model from the issue text.
Description
With the msgspec preconf converter, nested instances of a recursive attrs class are encoded by msgspec.to_builtins instead of the class's cattrs hook, so a registered hook is ignored and private fields disappear below the top level.
Minimal example
from __future__ import annotations
from datetime import datetime, timezone
from attrs import define
from cattrs.preconf.msgspec import make_converter
c = make_converter()
c.register_unstructure_hook(datetime, lambda d: int(d.timestamp()))
@define
class Event:
at: datetime
children: list[Event]
ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
observed = c.unstructure(Event(ts, [Event(ts, [])]))
print("Event observed:", observed)
print("Event expected:", {"at": 1767225600, "children": [{"at": 1767225600, "children": []}]})
@define
class A:
bs: list[B]
@define
class B:
_secret: int
as_: list[A]
observed = c.unstructure(A([B(1, [A([B(2, [])])])]))
print("A/B observed: ", observed)
print("A/B expected: ", {"bs": [{"_secret": 1, "as_": [{"bs": [{"_secret": 2, "as_": []}]}]}]})
Observed output
Event observed: {'at': 1767225600, 'children': [{'at': '2026-01-01T00:00:00Z', 'children': []}]}
Event expected: {'at': 1767225600, 'children': [{'at': 1767225600, 'children': []}]}
A/B observed: {'bs': [{'_secret': 1, 'as_': [{'bs': [{'as_': []}]}]}]}
A/B expected: {'bs': [{'_secret': 1, 'as_': [{'bs': [{'_secret': 2, 'as_': []}]}]}]}
The top-level at goes through the hook, the nested one does not. In the mutually recursive case the nested B loses _secret with no error.
Expected behaviour
Every level of a recursive class is unstructured by the same cattrs hook, as in 26.1.0, which prints the expected line for both cases with the same script. This was run on Python 3.12; the result is the same on 3.14.
Versions
- Python 3.12.3
- cattrs 26.2.1 (26.1.0 is not affected)
- attrs 26.1.0
- msgspec 0.22.0
- OS: Linux
Cause
msgspec_attrs_unstructure_factory in src/cattrs/preconf/msgspec.py (main at 73fa273, lines 207-226) returns to_builtins for any class already in _already_probing.working_set. The call converter.gen_unstructure_attrs_fromdict(type) at line 222 runs inside the same try, while the class is still in the working set. make_dict_unstructure_fn looks up each field handler with converter.get_unstructure_hook(t, cache_result=False) (src/cattrs/gen/__init__.py line 175), so for children: list[Event] the lookup comes back into the factory, hits the guard and gets to_builtins, which is then baked into the generated function as the handler for children.
The mutual recursion case goes through the private-attribute early return at lines 204-205: B is generated there while the outer probe for A still holds A in the working set, so B.as_ also gets to_builtins.
Proposed fix
Use the guard's to_builtins answer only for the probe decision and never generate a hook while any probe is active: from both generation points (the private-attribute return and the one after the probe), return converter.unstructure while _already_probing.working_set is non-empty, and generate only once the outermost probe has finished. Moving only the second call after the finally fixes Event but not the A/B case. A version-independent test in tests/preconf/test_msgspec_cpython.py with a recursive class that has a hooked or private field, plus the mutually recursive case, would cover it; the existing recursion test only has a list[RecursiveAttrs] field and only runs on 3.14.
I'm happy to open a PR with this fix and the tests.
- Dominant language
- Python
- Stars
- 1.1k
- Forks
- 171
- Avg merge
- 14h 46m
- Merged PRs (30d)
- 5
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from python-attrs/cattrs
-
Dataclass default_factory field treated as required in union disambiguationPossibly taken @Eryx-Z claimed this 1 day ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
python-attrs/cattrs#796 ·
Maintainers usually reply within 1 day
-
decimal.Decimal not handled in preconf converters (json, pyyaml)Possibly taken @Binilkks claimed this 113 days ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
python-attrs/cattrs#761 · 2 comments ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 64/100
python-attrs/cattrs#513 · 4 comments ·
Maintainers usually reply within 1 day
-
Structure hook on a PEP 695 alias is ignored inside a passthrough unionPossibly taken @Eryx-Z claimed this 1 day ago. Open
Difficulty 3/5 1-2 days Newbie friendliness 62/100
python-attrs/cattrs#797 ·
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 25/100
python-attrs/cattrs#794 ·
Maintainers usually reply within 1 day
All issues in python-attrs/cattrs
Similar issues
-
`lvm_compact` has no self-hosted twin, and `docs/158` §5 says every language-face component mustOpenbug sev-3 vm
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
sierra-research/tau2-bench#635 ·
Maintainers usually reply within 1 day
-
The add-ssm-model skill requires a Cython simulator; the contributing guide allows Python onesPossibly taken @fmuia claimed this today. Opendocumentation skill
Difficulty 2/5 1-3 hours Newbie friendliness 83/100
lnccbrown/ssm-simulators#406 ·
Maintainers usually reply within 4 days
-
Difficulty 1/5 Under an hour Newbie friendliness 95/100
btclib-org/btclib-ecc#309 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
autowarefoundation/autoware_carla_scenario#281 ·
Maintainers usually reply within 1 day