msgspec converter skips hooks and drops private fields in nested recursion
Maintainer thường phản hồi trong vòng 1 ngày
@Gabrieljr42 đang làm issue này rồi.
Từ ngày 16/10/2026.
Đánh giá
- Độ khó
- 3/5
- Thời gian dự kiến
- Nửa ngày
- Mức phù hợp với người mới
- 64/100
Hướng nghiên cứu
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.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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.
- Ngôn ngữ chính
- Python
- Star
- 1.1k
- Fork
- 177
- Merge trung bình
- 14 giờ 46 phút
- Pull request đã merge (30 ngày)
- 5
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Không có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của python-attrs/cattrs
-
Dataclass default_factory field treated as required in union disambiguationCó thể đã có người làm @Eryx-Z đã nhận 3 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
python-attrs/cattrs#796 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
decimal.Decimal not handled in preconf converters (json, pyyaml)Có thể đã có người làm @Binilkks đã nhận 115 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
python-attrs/cattrs#761 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 64/100
python-attrs/cattrs#513 · 4 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Structure hook on a PEP 695 alias is ignored inside a passthrough unionCó thể đã có người làm @Eryx-Z đã nhận 3 ngày trước. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 62/100
python-attrs/cattrs#797 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Getting the next structure hookĐang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 25/100
python-attrs/cattrs#794 ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của python-attrs/cattrs
Issue tương tự
-
bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 79/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
mohitmishra786/readprism#322 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
good first issue
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 75/100
Maintainer thường phản hồi trong vòng 1 ngày
-
API-key credentials accept a blank key: empty via the API, spaces-only via the builder's Add API key dialogCó thể đã có người làm Có pull request liên kết đang mở hoặc đã được merge. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 77/100
Significant-Gravitas/AutoGPT#15727 ·
Maintainer thường phản hồi trong vòng 1 ngày