diff --git a/HISTORY.md b/HISTORY.md index c0d5be50..d0643cae 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -31,6 +31,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#707](https://github.com/python-attrs/cattrs/issues/707) [#708](https://github.com/python-attrs/cattrs/pull/708)) - Enum handling has been optimized by switching to hook factories, improving performance especially for plain enums. ([#705](https://github.com/python-attrs/cattrs/pull/705)) +- Fix `include_subclasses` when used with `configure_tagged_union` and classes using diamond inheritance. + ([#685](https://github.com/python-attrs/cattrs/issues/685) [#713](https://github.com/python-attrs/cattrs/pull/713)) ## 25.3.0 (2025-10-07) diff --git a/src/cattrs/strategies/_subclasses.py b/src/cattrs/strategies/_subclasses.py index 41f812aa..695b0115 100644 --- a/src/cattrs/strategies/_subclasses.py +++ b/src/cattrs/strategies/_subclasses.py @@ -15,9 +15,13 @@ def _make_subclasses_tree(cl: type) -> list[type]: # get class origin for accessing subclasses (see #648 for more info) cls_origin = typing.get_origin(cl) or cl - return [cl] + [ - sscl for scl in subclasses(cls_origin) for sscl in _make_subclasses_tree(scl) - ] + + # Use a dict to deduplicate and keep insertion order. + seen = {cl: None} + for scl in subclasses(cls_origin): + for sscl in _make_subclasses_tree(scl): + seen[sscl] = None + return list(seen) def _has_subclasses(cl: type, given_subclasses: tuple[type, ...]) -> bool: diff --git a/tests/strategies/test_include_subclasses.py b/tests/strategies/test_include_subclasses.py index e45c7fe0..d485c18e 100644 --- a/tests/strategies/test_include_subclasses.py +++ b/tests/strategies/test_include_subclasses.py @@ -509,3 +509,30 @@ class ChildDC1(ParentDC): include_subclasses(ParentDC, genconverter) assert genconverter.structure({"a": 1, "b": "a"}, ParentDC) == ChildDC1(1, "a") + + +def test_diamond_inheritance(genconverter: Converter): + """Diamond inheritance is handled correctly (issue #685).""" + + @define + class Base: + pass + + @define + class Mid1(Base): + pass + + @define + class Mid2(Base): + pass + + @define + class Sub(Mid1, Mid2): + pass + + # This should not raise an error + include_subclasses(Base, genconverter, union_strategy=configure_tagged_union) + + assert genconverter.structure({"_type": "Sub"}, Base) == Sub() + assert genconverter.structure({"_type": "Mid1"}, Base) == Mid1() + assert genconverter.structure({"_type": "Mid2"}, Base) == Mid2()