Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

msgspec converter skips hooks and drops private fields in nested recursion

Đang mở
#795 1 bình luận 0 reaction 0 người được giao Xem trên GitHub

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.

  • #798 của @Gabrieljr42 — đang mở
  • #803 của @digit50 — đang mở

Đá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
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
python
Lĩnh vực
data

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

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. 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.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của python-attrs/cattrs

Tất cả issue của python-attrs/cattrs

Issue tương tự

Thêm issue về Python

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.