Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cattrs.preconf.tomllib>` preconf converter.
Expand Down
8 changes: 8 additions & 0 deletions src/cattrs/strategies/_subclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
91 changes: 90 additions & 1 deletion tests/strategies/test_include_subclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
)