From 9d8173371a8ed88364d635e963ea91bbf9d1e793 Mon Sep 17 00:00:00 2001 From: Andreas Albert Date: Fri, 17 Apr 2026 12:00:50 +0200 Subject: [PATCH 1/4] fix: Propagate column overrides through grandchild schemas Column overrides defined on a child schema were not shadowing the parent's definition when traversing bases during grandchild creation, causing `ImplementationError: Columns {...} are duplicated with conflicting definitions.` Apply `_remove_overridden_columns` while walking base metadata recursively so overrides propagate through inheritance chains of any depth. Fixes #329 Co-Authored-By: Claude Opus 4.7 --- dataframely/_base_schema.py | 5 +++++ tests/schema/test_inheritance.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/dataframely/_base_schema.py b/dataframely/_base_schema.py index f1506fd5..eeedf863 100644 --- a/dataframely/_base_schema.py +++ b/dataframely/_base_schema.py @@ -242,6 +242,11 @@ def _get_metadata_recursively(kls: type[object]) -> Metadata: result = Metadata() for base in kls.__bases__: result.update(SchemaMeta._get_metadata_recursively(base)) + SchemaMeta._remove_overridden_columns( + result, + kls.__dict__, # type: ignore[arg-type] + kls.__bases__, + ) result.update(SchemaMeta._get_metadata(kls.__dict__)) # type: ignore return result diff --git a/tests/schema/test_inheritance.py b/tests/schema/test_inheritance.py index 083fe689..46258228 100644 --- a/tests/schema/test_inheritance.py +++ b/tests/schema/test_inheritance.py @@ -20,3 +20,27 @@ def test_columns() -> None: assert ParentSchema.column_names() == ["a"] assert ChildSchema.column_names() == ["a", "b"] assert GrandchildSchema.column_names() == ["a", "b", "c"] + + +class OverrideBase(dy.Schema): + amt = dy.Float64(nullable=True) + + +class OverrideChild(OverrideBase): + amt = dy.Float64(nullable=False) + + +class OverrideGrandchild(OverrideChild): + pass + + +class OverrideGreatGrandchild(OverrideGrandchild): + other = dy.Integer() + + +def test_column_override_propagates_to_grandchild() -> None: + assert OverrideBase.columns()["amt"].nullable is True + assert OverrideChild.columns()["amt"].nullable is False + assert OverrideGrandchild.columns()["amt"].nullable is False + assert OverrideGreatGrandchild.columns()["amt"].nullable is False + assert OverrideGreatGrandchild.column_names() == ["amt", "other"] From 67ce3f6f5c2040c9863807ceed46d2f6e22366eb Mon Sep 17 00:00:00 2001 From: Andreas Albert Date: Fri, 17 Apr 2026 14:10:03 +0200 Subject: [PATCH 2/4] refactor: Extract `_collect_metadata` to share override merge logic `SchemaMeta.__new__` and `_get_metadata_recursively` were duplicating the walk-bases / drop-overrides / merge-namespace sequence. Centralise it in a single helper so both paths stay in sync and future override semantics only need to change in one place. Co-Authored-By: Claude Opus 4.7 --- dataframely/_base_schema.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/dataframely/_base_schema.py b/dataframely/_base_schema.py index eeedf863..732cc008 100644 --- a/dataframely/_base_schema.py +++ b/dataframely/_base_schema.py @@ -116,12 +116,7 @@ def __new__( *args: Any, **kwargs: Any, ) -> SchemaMeta: - result = Metadata() - for base in bases: - result.update(mcs._get_metadata_recursively(base)) - namespace_metadata = mcs._get_metadata(namespace) - mcs._remove_overridden_columns(result, namespace, bases) - result.update(namespace_metadata) + result = mcs._collect_metadata(bases, namespace) namespace[_COLUMN_ATTR] = result.columns cls = super().__new__(mcs, name, bases, namespace, *args, **kwargs) @@ -238,18 +233,21 @@ def _remove_overridden_columns( result.columns.pop(parent_key, None) @staticmethod - def _get_metadata_recursively(kls: type[object]) -> Metadata: + def _collect_metadata( + bases: tuple[type[object], ...], + namespace: dict[str, Any], + ) -> Metadata: result = Metadata() - for base in kls.__bases__: + for base in bases: result.update(SchemaMeta._get_metadata_recursively(base)) - SchemaMeta._remove_overridden_columns( - result, - kls.__dict__, # type: ignore[arg-type] - kls.__bases__, - ) - result.update(SchemaMeta._get_metadata(kls.__dict__)) # type: ignore + SchemaMeta._remove_overridden_columns(result, namespace, bases) + result.update(SchemaMeta._get_metadata(namespace)) return result + @staticmethod + def _get_metadata_recursively(kls: type[object]) -> Metadata: + return SchemaMeta._collect_metadata(kls.__bases__, kls.__dict__) # type: ignore[arg-type] + @staticmethod def _get_metadata(source: dict[str, Any]) -> Metadata: result = Metadata() From caee5d304cc7f7abe44ecf6d739157d4f982dc02 Mon Sep 17 00:00:00 2001 From: Andreas Albert Date: Fri, 17 Apr 2026 15:14:15 +0200 Subject: [PATCH 3/4] refactor: Accept `Mapping` for namespace params to drop type ignore Widen the `namespace`/`source` parameter types from `dict[str, Any]` to `Mapping[str, Any]` so that `kls.__dict__` (a `mappingproxy`) can be passed without a `# type: ignore[arg-type]` cast. Addresses copilot review feedback on #330. Co-Authored-By: Claude Opus 4.7 --- dataframely/_base_schema.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dataframely/_base_schema.py b/dataframely/_base_schema.py index 732cc008..8c64e671 100644 --- a/dataframely/_base_schema.py +++ b/dataframely/_base_schema.py @@ -6,6 +6,7 @@ import sys import textwrap from abc import ABCMeta +from collections.abc import Mapping from copy import copy from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -207,7 +208,7 @@ def __getattribute__(cls, name: str) -> Any: @staticmethod def _remove_overridden_columns( result: Metadata, - namespace: dict[str, Any], + namespace: Mapping[str, Any], bases: tuple[type[object], ...], ) -> None: """Remove inherited columns that the child namespace explicitly overrides. @@ -235,7 +236,7 @@ def _remove_overridden_columns( @staticmethod def _collect_metadata( bases: tuple[type[object], ...], - namespace: dict[str, Any], + namespace: Mapping[str, Any], ) -> Metadata: result = Metadata() for base in bases: @@ -246,10 +247,10 @@ def _collect_metadata( @staticmethod def _get_metadata_recursively(kls: type[object]) -> Metadata: - return SchemaMeta._collect_metadata(kls.__bases__, kls.__dict__) # type: ignore[arg-type] + return SchemaMeta._collect_metadata(kls.__bases__, kls.__dict__) @staticmethod - def _get_metadata(source: dict[str, Any]) -> Metadata: + def _get_metadata(source: Mapping[str, Any]) -> Metadata: result = Metadata() for attr, value in { k: v for k, v in source.items() if not k.startswith("__") From 76793ba7c927deb3d4ed6b472bb8471b92d0226e Mon Sep 17 00:00:00 2001 From: Andreas Albert Date: Fri, 17 Apr 2026 16:47:38 +0200 Subject: [PATCH 4/4] empty