Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

msgspec converter skips hooks and drops private fields in nested recursion

Abierto
#795 0 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

@Gabrieljr42 ya está trabajando en esto.

Desde el 16/10/2026.

  • #798 de @Gabrieljr42 — abierto

Evaluación

Dificultad
3/5
Tiempo estimado
Medio día
Aptitud para principiantes
64/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
python
Área
data

Línea de trabajo

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.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

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.

Lenguaje dominante
Python
Estrellas
1.1k
Forks
171
Merge medio
14 h 46 min
PR fusionados (30 d)
5

Preparar el entorno

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de python-attrs/cattrs

Todos los issues de python-attrs/cattrs

Issues similares

Más issues de Python

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.