Skip to content

Commit 573915b

Browse files
mm-andritzmatmel
andauthored
Working set fix (#722)
* Add failing test * Implement the fix * Document the test --------- Co-authored-by: Matthieu Melot <matthieu.melot@gmail.com>
1 parent a4230d1 commit 573915b

File tree

3 files changed

+103
-1
lines changed

3 files changed

+103
-1
lines changed

HISTORY.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ The third number is for emergencies when we need to start branches for older rel
1111

1212
Our backwards-compatibility policy can be found [here](https://github.com/python-attrs/cattrs/blob/main/.github/SECURITY.md).
1313

14+
## NEXT (UNRELEASED)
15+
16+
- Fix an `AttributeError` in `cattrs` internals that could be triggered by using the `include_subclasses` strategy in a `structure_hook_factory`
17+
([#721](https://github.com/python-attrs/cattrs/issues/721), [#722](https://github.com/python-attrs/cattrs/pull/722))
18+
1419
## 26.1.0 (2026-02-18)
1520

1621
- Add the {mod}`tomllib <cattrs.preconf.tomllib>` preconf converter.

src/cattrs/strategies/_subclasses.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ def _include_subclasses_with_union_strategy(
191191

192192
original_unstruct_hooks = {}
193193
original_struct_hooks = {}
194+
195+
original_working_set = None
196+
if hasattr(already_generating, "working_set"):
197+
original_working_set = already_generating.working_set.copy()
198+
194199
for cl in union_classes:
195200
# In the first pass, every class gets its own unstructure function according to
196201
# the overrides.
@@ -209,6 +214,9 @@ def _include_subclasses_with_union_strategy(
209214
original_unstruct_hooks[cl] = unstruct_hook
210215
original_struct_hooks[cl] = struct_hook
211216

217+
if original_working_set is not None:
218+
already_generating.working_set = original_working_set
219+
212220
# Now that's done, we can register all the hooks and generate the
213221
# union handler. The union handler needs them.
214222
final_union = Union[union_classes] # type: ignore

tests/strategies/test_include_subclasses.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
from typing import Any
77

88
import pytest
9-
from attrs import define
9+
from attrs import define, frozen, has
1010

1111
from cattrs import Converter, override
1212
from cattrs.errors import ClassValidationError, StructureHandlerNotFoundError
13+
from cattrs.gen import make_dict_structure_fn
1314
from cattrs.strategies import configure_tagged_union, include_subclasses
1415

1516
from .._compat import is_py311_plus
@@ -536,3 +537,91 @@ class Sub(Mid1, Mid2):
536537
assert genconverter.structure({"_type": "Sub"}, Base) == Sub()
537538
assert genconverter.structure({"_type": "Mid1"}, Base) == Mid1()
538539
assert genconverter.structure({"_type": "Mid2"}, Base) == Mid2()
540+
541+
542+
def test_subclasses_in_struct_factory():
543+
"""
544+
Check the structuring does not fail with an attribute error when include_subclasses
545+
is called within a structure_hook_factory on a complex class tree involving
546+
subclasses several levels deep (#721)
547+
"""
548+
549+
@frozen
550+
class SubA:
551+
id: int
552+
sub_a: str
553+
554+
@frozen
555+
class SubA1(SubA):
556+
pass
557+
558+
@frozen
559+
class A:
560+
"""Base class"""
561+
562+
s: SubA
563+
564+
@frozen
565+
class A1(A):
566+
a1: int
567+
568+
@frozen
569+
class A2(A):
570+
a2: int
571+
572+
@frozen
573+
class B:
574+
id: int
575+
b: str
576+
577+
@frozen
578+
class Container1:
579+
id: int
580+
a: A
581+
b: B
582+
583+
@frozen
584+
class Container2:
585+
id: int
586+
c: Container1
587+
foo: str
588+
589+
def struct_hook_factory(cl, converter: Converter):
590+
struct_hook = make_dict_structure_fn(cl, converter)
591+
if not cl.__subclasses__():
592+
converter.register_structure_hook(cl, struct_hook)
593+
594+
else:
595+
596+
def cls_is_cl(cls, _cl=cl):
597+
return cls is _cl
598+
599+
converter.register_structure_hook_func(cls_is_cl, struct_hook)
600+
union_strategy = partial(configure_tagged_union, tag_name="type")
601+
include_subclasses(cl, converter, union_strategy=union_strategy)
602+
603+
return converter.get_structure_hook(cl)
604+
605+
converter = Converter()
606+
converter.register_structure_hook_factory(has, struct_hook_factory)
607+
608+
unstructured = {
609+
"id": 0,
610+
"c": {
611+
"id": 1,
612+
"a": {
613+
"type": "A1",
614+
"s": {"type": "SubA1", "id": 2, "sub_a": "a"},
615+
"a1": 42,
616+
},
617+
"b": {"id": 3, "b": "hello"},
618+
},
619+
"foo": "world",
620+
}
621+
res = converter.structure(unstructured, Container2)
622+
623+
assert res == Container2(
624+
id=0,
625+
c=Container1(id=1, a=A1(s=SubA1(id=2, sub_a="a"), a1=42), b=B(id=3, b="hello")),
626+
foo="world",
627+
)

0 commit comments

Comments
 (0)