diff --git a/HISTORY.md b/HISTORY.md index 156d50a6..f8f7df14 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -11,6 +11,11 @@ The third number is for emergencies when we need to start branches for older rel Our backwards-compatibility policy can be found [here](https://github.com/python-attrs/cattrs/blob/main/.github/SECURITY.md). +## NEXT (UNRELEASED) + +- Fix an `AttributeError` in `cattrs` internals that could be triggered by using the `include_subclasses` strategy in a `structure_hook_factory` + ([#721](https://github.com/python-attrs/cattrs/issues/721), [#722](https://github.com/python-attrs/cattrs/pull/722)) + ## 26.1.0 (2026-02-18) - Add the {mod}`tomllib ` preconf converter. diff --git a/src/cattrs/strategies/_subclasses.py b/src/cattrs/strategies/_subclasses.py index 695b0115..3239be78 100644 --- a/src/cattrs/strategies/_subclasses.py +++ b/src/cattrs/strategies/_subclasses.py @@ -191,6 +191,11 @@ def _include_subclasses_with_union_strategy( original_unstruct_hooks = {} original_struct_hooks = {} + + original_working_set = None + if hasattr(already_generating, "working_set"): + original_working_set = already_generating.working_set.copy() + for cl in union_classes: # In the first pass, every class gets its own unstructure function according to # the overrides. @@ -209,6 +214,9 @@ def _include_subclasses_with_union_strategy( original_unstruct_hooks[cl] = unstruct_hook original_struct_hooks[cl] = struct_hook + if original_working_set is not None: + already_generating.working_set = original_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..bce346f0 100644 --- a/tests/strategies/test_include_subclasses.py +++ b/tests/strategies/test_include_subclasses.py @@ -6,10 +6,11 @@ from typing import Any import pytest -from attrs import define +from attrs import define, frozen, has from cattrs import Converter, override from cattrs.errors import ClassValidationError, StructureHandlerNotFoundError +from cattrs.gen import make_dict_structure_fn from cattrs.strategies import configure_tagged_union, include_subclasses from .._compat import is_py311_plus @@ -536,3 +537,91 @@ 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_subclasses_in_struct_factory(): + """ + Check the structuring does not fail with an attribute error when include_subclasses + is called within a structure_hook_factory on a complex class tree involving + subclasses several levels deep (#721) + """ + + @frozen + class SubA: + id: int + sub_a: str + + @frozen + class SubA1(SubA): + pass + + @frozen + class A: + """Base class""" + + s: SubA + + @frozen + class A1(A): + a1: int + + @frozen + class A2(A): + a2: 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: 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 = Converter() + converter.register_structure_hook_factory(has, struct_hook_factory) + + unstructured = { + "id": 0, + "c": { + "id": 1, + "a": { + "type": "A1", + "s": {"type": "SubA1", "id": 2, "sub_a": "a"}, + "a1": 42, + }, + "b": {"id": 3, "b": "hello"}, + }, + "foo": "world", + } + res = converter.structure(unstructured, Container2) + + assert res == Container2( + id=0, + c=Container1(id=1, a=A1(s=SubA1(id=2, sub_a="a"), a1=42), b=B(id=3, b="hello")), + foo="world", + )