From fc8449a5f797325ddc7fcbf383c881f26c8fd7ba Mon Sep 17 00:00:00 2001 From: Kadir Can Ozden <101993364+bysiber@users.noreply.github.com> Date: Sun, 22 Feb 2026 04:40:01 +0300 Subject: [PATCH] Save and restore working_set in include_subclasses When include_subclasses is called from within a structure hook factory (which is itself invoked during make_dict_structure_fn), it would overwrite already_generating.working_set with its own set and then reset it to an empty set. This caused the outer make_dict_structure_fn to fail with AttributeError when trying to clean up its working_set. Now the existing working_set is saved before the loop and restored after, so nested calls work correctly. --- src/cattrs/strategies/_subclasses.py | 14 +++++ tests/strategies/test_include_subclasses.py | 60 +++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/cattrs/strategies/_subclasses.py b/src/cattrs/strategies/_subclasses.py index 695b0115..e5771b98 100644 --- a/src/cattrs/strategies/_subclasses.py +++ b/src/cattrs/strategies/_subclasses.py @@ -191,6 +191,14 @@ def _include_subclasses_with_union_strategy( original_unstruct_hooks = {} original_struct_hooks = {} + + # Save the existing working_set so we can restore it after the loop. + # include_subclasses may be called while make_dict_structure_fn is + # already generating hooks for outer classes (via a hook factory), + # so we must not clobber the outer working_set. + _had_working_set = hasattr(already_generating, "working_set") + _prev_working_set = getattr(already_generating, "working_set", None) + for cl in union_classes: # In the first pass, every class gets its own unstructure function according to # the overrides. @@ -209,6 +217,12 @@ def _include_subclasses_with_union_strategy( original_unstruct_hooks[cl] = unstruct_hook original_struct_hooks[cl] = struct_hook + # Restore the previous working_set state. + if _had_working_set: + already_generating.working_set = _prev_working_set + elif hasattr(already_generating, "working_set"): + del already_generating.working_set + # Now that's done, we can register all the hooks and generate the # union handler. The union handler needs them. final_union = Union[union_classes] # type: ignore diff --git a/tests/strategies/test_include_subclasses.py b/tests/strategies/test_include_subclasses.py index d485c18e..c1e1d5c5 100644 --- a/tests/strategies/test_include_subclasses.py +++ b/tests/strategies/test_include_subclasses.py @@ -536,3 +536,63 @@ class Sub(Mid1, Mid2): assert genconverter.structure({"_type": "Sub"}, Base) == Sub() assert genconverter.structure({"_type": "Mid1"}, Base) == Mid1() assert genconverter.structure({"_type": "Mid2"}, Base) == Mid2() + + +def test_include_subclasses_in_hook_factory(): + """include_subclasses called from within a structure hook factory should + not clobber the outer already_generating working_set (#721).""" + from attrs import frozen, has + from cattrs.gen import make_dict_structure_fn + from cattrs.preconf.json import make_converter + + @frozen + class A: + pass + + @frozen + class A1(A): + a1: int + + @frozen + class B: + id: int + b: str + + @frozen + class Container1: + id: int + a: A + b: B + + @frozen + class Container2: + id: int + c: Container1 + foo: str + + def struct_hook_factory(cl, converter): + struct_hook = make_dict_structure_fn(cl, converter) + if not cl.__subclasses__(): + converter.register_structure_hook(cl, struct_hook) + else: + def cls_is_cl(cls, _cl=cl): + return cls is _cl + converter.register_structure_hook_func(cls_is_cl, struct_hook) + union_strategy = partial(configure_tagged_union, tag_name="type") + include_subclasses(cl, converter, union_strategy=union_strategy) + return converter.get_structure_hook(cl) + + converter = make_converter() + converter.register_structure_hook_factory(has, struct_hook_factory) + + unstructured = { + "id": 0, + "c": {"id": 1, "a": {"type": "A1", "a1": 42}, "b": {"id": 2, "b": "hello"}}, + "foo": "world", + } + result = converter.structure(unstructured, Container2) + assert result == Container2( + id=0, + c=Container1(id=1, a=A1(a1=42), b=B(id=2, b="hello")), + foo="world", + )