From 9535910b9e92d16132e25f4dcaec256d483c00f1 Mon Sep 17 00:00:00 2001 From: Liyu0310-Code <319043308+Liyu0310-Code@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:51:32 +0800 Subject: [PATCH 1/2] Fix DataClassNode slot accumulation under multiple inheritance --- graphtage/dataclasses.py | 14 ++++++++------ test/test_dataclasses.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/graphtage/dataclasses.py b/graphtage/dataclasses.py index b8d94db..b5ed482 100644 --- a/graphtage/dataclasses.py +++ b/graphtage/dataclasses.py @@ -120,11 +120,13 @@ def __init_subclass__(cls, **kwargs): for a in ancestors for name in a._SLOTS } - if not hasattr(cls, "_SLOT_ANNOTATIONS") or cls._SLOT_ANNOTATIONS is None: - cls._SLOT_ANNOTATIONS = {} - cls._SLOTS = () - else: - cls._SLOT_ANNOTATIONS = dict(cls._SLOT_ANNOTATIONS) + # Collect the inherited slots from *all* data-class ancestors, in reverse-MRO order. + # Reading the inherited `_SLOT_ANNOTATIONS`/`_SLOTS` attributes instead would follow + # only the first inheritance chain, silently dropping the slots of any additional bases. + inherited_slot_annotations: dict[str, type[TreeNode]] = {} + for ancestor in reversed(ancestors): + inherited_slot_annotations.update(ancestor._SLOT_ANNOTATIONS) + cls._SLOT_ANNOTATIONS = inherited_slot_annotations new_slots = [] for name, slot_type in cls.__annotations__.items(): # get_origin() screens out subscripted generics before issubclass() sees them. On Python @@ -138,7 +140,7 @@ def __init_subclass__(cls, **kwargs): f"defined in its superclass {ancestor_slot_names[name].__name__}") new_slots.append(name) cls._SLOT_ANNOTATIONS[name] = slot_type - cls._SLOTS = cls._SLOTS + tuple(new_slots) + cls._SLOTS = tuple(cls._SLOT_ANNOTATIONS) def __hash__(self): return self.__hash diff --git a/test/test_dataclasses.py b/test/test_dataclasses.py index bd60df8..7ed712f 100644 --- a/test/test_dataclasses.py +++ b/test/test_dataclasses.py @@ -91,6 +91,41 @@ def post_init(self): self.name.quoted = False self.assertFalse(Unquoted(StringNode("name")).name.quoted) + + def test_multiple_inheritance(self): + """Slots from *all* bases must survive diamond inheritance, not just the first chain.""" + class Foo(DataClassNode): + foo: IntegerNode + + class Bar(Foo): + bar: StringNode + + class Baz(Foo): + baz: StringNode + + class Quux(Bar, Baz): + quux: IntegerNode + + self.assertEqual(("foo",), Foo._SLOTS) + self.assertEqual(("foo", "bar"), Bar._SLOTS) + self.assertEqual(("foo", "baz"), Baz._SLOTS) + self.assertEqual(("foo", "baz", "bar", "quux"), Quux._SLOTS) + self.assertEqual( + {"foo": IntegerNode, "baz": StringNode, "bar": StringNode, "quux": IntegerNode}, + Quux._SLOT_ANNOTATIONS + ) + + node = Quux(foo=IntegerNode(1), bar=StringNode("bar"), baz=StringNode("baz"), quux=IntegerNode(4)) + self.assertEqual(1, node.foo.object) + self.assertEqual("bar", node.bar.object) + self.assertEqual("baz", node.baz.object) + self.assertEqual(4, node.quux.object) + self.assertEqual({"foo", "bar", "baz", "quux"}, set(node.to_obj())) + + # diffing against an identical node yields a DataClassEdit, not a Replace + twin = Quux(foo=IntegerNode(1), bar=StringNode("bar"), baz=StringNode("baz"), quux=IntegerNode(4)) + self.assertIsInstance(node.edits(twin), DataClassEdit) + def test_print_renders_slots(self): """:meth:`DataClassNode.print` is the fallback when no formatter resolves the node type.""" class Foo(DataClassNode): From df537d610da614c36612caa2f4d378c9898e4bc8 Mon Sep 17 00:00:00 2001 From: Evan Sultanik Date: Wed, 9 Sep 2026 11:25:04 -0400 Subject: [PATCH 2/2] Correct the ancestor order and drop the unused slot list The slot accumulation added in the previous commit was written against an earlier `__init_subclass__`, where `ancestors` was in plain `__mro__` order and had to be reversed to merge annotations base-first. PR #166 has since rebuilt that list from `reversed(cls.__mro__)`, so it already arrives base-first and reversing it again inverted the merge order: a diamond `class D(B, C)` produced slots in `B`-then-`C` order rather than the declaration order the ancestors imply. Because none of the edited lines overlapped, the change applied cleanly on top of #166 while its meaning changed underneath it, and the accompanying test failed on the merge commit. Deriving `_SLOTS` from the merged annotations also left `new_slots` written but never read. Ruff does not report it, because `new_slots.append(...)` counts as a reference for F841. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GypKU5KdLfs2Cf8kS2TzJa --- graphtage/dataclasses.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/graphtage/dataclasses.py b/graphtage/dataclasses.py index b5ed482..6649c75 100644 --- a/graphtage/dataclasses.py +++ b/graphtage/dataclasses.py @@ -120,14 +120,14 @@ def __init_subclass__(cls, **kwargs): for a in ancestors for name in a._SLOTS } - # Collect the inherited slots from *all* data-class ancestors, in reverse-MRO order. - # Reading the inherited `_SLOT_ANNOTATIONS`/`_SLOTS` attributes instead would follow - # only the first inheritance chain, silently dropping the slots of any additional bases. + # Collect the inherited slots from *all* data-class ancestors, which `ancestors` already + # lists base-first. Reading the inherited `_SLOT_ANNOTATIONS`/`_SLOTS` attributes instead + # would follow only the first inheritance chain, silently dropping the slots of any + # additional bases. inherited_slot_annotations: dict[str, type[TreeNode]] = {} - for ancestor in reversed(ancestors): + for ancestor in ancestors: inherited_slot_annotations.update(ancestor._SLOT_ANNOTATIONS) cls._SLOT_ANNOTATIONS = inherited_slot_annotations - new_slots = [] for name, slot_type in cls.__annotations__.items(): # get_origin() screens out subscripted generics before issubclass() sees them. On Python # 3.10 isinstance(list[int], type) is True, so issubclass() would raise there. @@ -138,7 +138,6 @@ def __init_subclass__(cls, **kwargs): if name in ancestor_slot_names: raise TypeError(f"Dataclass {cls.__name__} cannot redefine slot {name!r} because it is already " f"defined in its superclass {ancestor_slot_names[name].__name__}") - new_slots.append(name) cls._SLOT_ANNOTATIONS[name] = slot_type cls._SLOTS = tuple(cls._SLOT_ANNOTATIONS)