From e88ff35478eaafe8b652628d600c81c10051c046 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Thu, 2 Apr 2026 23:50:14 +0200 Subject: [PATCH 1/2] Fix Union MRO inheritance causing RecursionError (#80) Add `inheritable` attribute to ClassDecoratorSetting (default True) and set it to False on Union, so Union settings are not inherited by subclasses through MRO traversal. This prevents infinite recursion when serializing/deserializing dataclasses registered as union members. Co-Authored-By: Claude Opus 4.6 --- databind/src/databind/core/settings.py | 5 +- .../databind/json/tests/converters_test.py | 91 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/databind/src/databind/core/settings.py b/databind/src/databind/core/settings.py index da8b562..028c1f1 100644 --- a/databind/src/databind/core/settings.py +++ b/databind/src/databind/core/settings.py @@ -160,6 +160,7 @@ def __init__(self) -> None: class ClassDecoratorSetting(Setting): bound_to: t.Optional[type] = None + inheritable: bool = True def __init__(self) -> None: if type(self) is ClassDecoratorSetting: @@ -203,7 +204,7 @@ def get_class_settings( for klass in type_.__mro__: for item in vars(klass).get("__databind_settings__", []): - if isinstance(item, setting_type): + if isinstance(item, setting_type) and (klass is type_ or item.inheritable): yield item @@ -417,6 +418,8 @@ class Union(ClassDecoratorSetting): #: the first or best succeeding result. No discriminator key is used. BEST_MATCH: t.ClassVar = "best_match" + inheritable: t.ClassVar[bool] = False + #: The subtypes of the union as an implementation of the #UnionMembers interface. When constructing the #Union #: setting, a dictionary may be passed in place of a #UnionMembers implementation, or a list of #UnionMembers #: to chain them together. Te constructor will also accept a string that is either `""`, which will diff --git a/databind/src/databind/json/tests/converters_test.py b/databind/src/databind/json/tests/converters_test.py index 8d17d70..6ace1c3 100644 --- a/databind/src/databind/json/tests/converters_test.py +++ b/databind/src/databind/json/tests/converters_test.py @@ -876,3 +876,94 @@ def test_union_literal() -> None: assert mapper.deserialize("bye", StrType) == "bye" assert mapper.deserialize("other", StrType) == "other" + + +def test_union_not_inherited_by_subclass() -> None: + """Regression test for #80: Union settings on a base class must not be inherited by subclasses + via MRO traversal, as this causes infinite recursion during serialization/deserialization.""" + + @Union(style=Union.FLAT) + @dataclasses.dataclass + class Base: + x: float = 0.0 + + @Union.register(Base, "a") + @dataclasses.dataclass + class A(Base): + pass + + @Union.register(Base, "b") + @dataclasses.dataclass + class B(Base): + y: float = 0.0 + + mapper = make_mapper([CollectionConverter(), UnionConverter(), SchemaConverter(), PlainDatatypeConverter()]) + + # Deserialize via union base type + result = mapper.deserialize({"type": "a", "x": 1.0}, Base) + assert result == A(x=1.0) + + # Serialize via union base type + result = mapper.serialize(A(x=1.0), Base) + assert result == {"type": "a", "x": 1.0} + + # Deserialize list of union base type + result = mapper.deserialize([{"type": "a", "x": 1.0}, {"type": "b", "x": 2.0, "y": 3.0}], t.List[Base]) + assert result == [A(x=1.0), B(x=2.0, y=3.0)] + + # Serialize list of union base type + result = mapper.serialize([A(x=1.0), B(x=2.0, y=3.0)], t.List[Base]) + assert result == [{"type": "a", "x": 1.0}, {"type": "b", "x": 2.0, "y": 3.0}] + + # Direct deserialization of a union member as its own type (not through the base) + result = mapper.deserialize({"x": 1.0}, A) + assert result == A(x=1.0) + + +def test_union_not_inherited_multi_level() -> None: + """Union must not be inherited through deeper MRO chains (Base -> Middle -> Leaf).""" + from databind.core.settings import get_class_settings + + @Union(style=Union.FLAT) + @dataclasses.dataclass + class Base: + x: float = 0.0 + + @Union.register(Base, "mid") + @dataclasses.dataclass + class Middle(Base): + pass + + @dataclasses.dataclass + class Leaf(Middle): + pass + + # Union should be found on Base (directly applied) but not on Middle or Leaf + assert list(get_class_settings(Base, Union)) != [] + assert list(get_class_settings(Middle, Union)) == [] + assert list(get_class_settings(Leaf, Union)) == [] + + +def test_subclass_with_own_union() -> None: + """A subclass that has its own @Union decorator should still have that setting found.""" + from databind.core.settings import get_class_settings + + @Union(style=Union.FLAT) + @dataclasses.dataclass + class Parent: + x: float = 0.0 + + @Union(style=Union.NESTED) + @dataclasses.dataclass + class Child(Parent): + y: float = 0.0 + + parent_unions = list(get_class_settings(Parent, Union)) + child_unions = list(get_class_settings(Child, Union)) + + assert len(parent_unions) == 1 + assert parent_unions[0].style == Union.FLAT + + # Child should find its own Union (NESTED), not inherit the parent's (FLAT) + assert len(child_unions) == 1 + assert child_unions[0].style == Union.NESTED From 426492b32095e8de08bee22cfbdcf71bc168ef5c Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Thu, 2 Apr 2026 23:53:06 +0200 Subject: [PATCH 2/2] Fix mypy error and add changelog entry Use ClassVar for inheritable on both ClassDecoratorSetting and Union to satisfy mypy. Add unreleased changelog entry for #80. Co-Authored-By: Claude Opus 4.6 --- .changelog/_unreleased.toml | 5 +++++ databind/src/databind/core/settings.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml index e69de29..c2f4f37 100644 --- a/.changelog/_unreleased.toml +++ b/.changelog/_unreleased.toml @@ -0,0 +1,5 @@ +[[entries]] +id = "f3a8c1d2-7e4b-4a9f-b6c5-2d1e8f9a0b3c" +type = "fix" +description = "Fix #80: Union setting inherited via MRO causing RecursionError during serialization/deserialization" +author = "@NiklasRosenstein" diff --git a/databind/src/databind/core/settings.py b/databind/src/databind/core/settings.py index 028c1f1..ce46059 100644 --- a/databind/src/databind/core/settings.py +++ b/databind/src/databind/core/settings.py @@ -160,7 +160,7 @@ def __init__(self) -> None: class ClassDecoratorSetting(Setting): bound_to: t.Optional[type] = None - inheritable: bool = True + inheritable: t.ClassVar[bool] = True def __init__(self) -> None: if type(self) is ClassDecoratorSetting: