From c58a9f572157902575fa807f9163ac8b0c973610 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:59:02 +0000 Subject: [PATCH 1/8] Initial plan From 563a4435b90c95f050c867538c67cdcad51c1832 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:04:47 +0000 Subject: [PATCH 2/8] Fix ExtraKeys decorator to create own __databind_settings__ on subclasses Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> Agent-Logs-Url: https://github.com/NiklasRosenstein/python-databind/sessions/beeda587-62ab-4243-ad49-d1a0c7b05759 --- databind/src/databind/core/settings.py | 2 +- .../databind/json/tests/converters_test.py | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/databind/src/databind/core/settings.py b/databind/src/databind/core/settings.py index 5777529..584344d 100644 --- a/databind/src/databind/core/settings.py +++ b/databind/src/databind/core/settings.py @@ -176,7 +176,7 @@ def __call__(self, type_: t.Type[T]) -> t.Type[T]: raise RuntimeError("cannot decorate multiple types with the same setting instance") self.bound_to = type_ - settings = getattr(type_, "__databind_settings__", None) + settings = vars(type_).get("__databind_settings__", None) if settings is None: settings = [] setattr(type_, "__databind_settings__", settings) diff --git a/databind/src/databind/json/tests/converters_test.py b/databind/src/databind/json/tests/converters_test.py index 9ef970f..26c919e 100644 --- a/databind/src/databind/json/tests/converters_test.py +++ b/databind/src/databind/json/tests/converters_test.py @@ -717,3 +717,70 @@ def of(cls, v: str) -> "MyCls": mapper = make_mapper([JsonConverterSupport()]) assert mapper.serialize(MyCls(), MyCls) == "MyCls" assert mapper.deserialize("MyCls", MyCls) == MyCls() + + +def test_extra_keys_on_subclass_creates_own_settings() -> None: + """Regression test: ExtraKeys() applied to a subclass must create its own __databind_settings__, + not append to the parent's list via MRO traversal.""" + from databind.core.settings import get_class_settings + + @ExtraKeys() + @dataclasses.dataclass + class Parent: + a: int + + @ExtraKeys() + @dataclasses.dataclass + class Child(Parent): + b: str = "" + + # Each class must have its own independent __databind_settings__ list. + assert "__databind_settings__" in vars(Parent) + assert "__databind_settings__" in vars(Child) + assert vars(Parent)["__databind_settings__"] is not vars(Child)["__databind_settings__"] + + # get_class_settings must find ExtraKeys on each class independently. + assert list(get_class_settings(Parent, ExtraKeys)) != [] + assert list(get_class_settings(Child, ExtraKeys)) != [] + + # Parent's settings list must not be polluted with Child's decorator. + assert len(vars(Parent)["__databind_settings__"]) == 1 + + +def test_extra_keys_subclass_deserialization_allows_extra_keys() -> None: + """Regression test: ExtraKeys() on a subclass must allow extra keys during deserialization.""" + mapper = make_mapper([SchemaConverter(), PlainDatatypeConverter()]) + + @ExtraKeys() + @dataclasses.dataclass + class Parent: + a: int + + @ExtraKeys() + @dataclasses.dataclass + class Child(Parent): + b: str = "" + + # Child should allow extra keys because it is decorated with ExtraKeys(). + result = mapper.deserialize({"a": 1, "b": "hello", "extra": "ignored"}, Child) + assert result == Child(a=1, b="hello") + + +def test_extra_keys_parent_decorated_child_not_decorated_raises() -> None: + """When only the parent has ExtraKeys(), the child should NOT inherit that permission + via get_class_settings (which uses vars()), so extra keys on the child raise an error.""" + mapper = make_mapper([SchemaConverter(), PlainDatatypeConverter()]) + + @ExtraKeys() + @dataclasses.dataclass + class Parent: + a: int + + @dataclasses.dataclass + class Child(Parent): + b: str = "" + + # Child has no ExtraKeys() of its own; extra keys should cause an error. + with pytest.raises(ConversionError) as excinfo: + mapper.deserialize({"a": 1, "b": "hello", "extra": "ignored"}, Child) + assert "extra" in str(excinfo.value) From 3a1eeaf0a1ed8011444593b5f44bf9e1898bb21d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:12:48 +0000 Subject: [PATCH 3/8] Fix get_class_settings to traverse MRO so subclasses inherit parent settings Co-authored-by: NiklasRosenstein <1318438+NiklasRosenstein@users.noreply.github.com> Agent-Logs-Url: https://github.com/NiklasRosenstein/python-databind/sessions/f66fb149-678a-494c-8bd0-69cbec04fc7f --- databind/src/databind/core/settings.py | 13 +++++++----- .../databind/json/tests/converters_test.py | 21 +++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/databind/src/databind/core/settings.py b/databind/src/databind/core/settings.py index 584344d..da8b562 100644 --- a/databind/src/databind/core/settings.py +++ b/databind/src/databind/core/settings.py @@ -197,11 +197,14 @@ def get_highest_setting(settings: t.Iterable[T_Setting]) -> "T_Setting | None": def get_class_settings( type_: type, setting_type: t.Type[T_ClassDecoratorSetting] ) -> t.Iterable[T_ClassDecoratorSetting]: - """Returns all matching settings on *type_*.""" - - for item in vars(type_).get("__databind_settings__", []): - if isinstance(item, setting_type): - yield item + """Returns all matching settings on *type_*, traversing the MRO so that parent class settings + are inherited by subclasses. Settings defined on the class itself are yielded before those + inherited from parent classes, giving them priority when priority levels are equal.""" + + for klass in type_.__mro__: + for item in vars(klass).get("__databind_settings__", []): + if isinstance(item, setting_type): + yield item def get_class_setting(type_: type, setting_type: t.Type[T_ClassDecoratorSetting]) -> "T_ClassDecoratorSetting | None": diff --git a/databind/src/databind/json/tests/converters_test.py b/databind/src/databind/json/tests/converters_test.py index 26c919e..1e9e77e 100644 --- a/databind/src/databind/json/tests/converters_test.py +++ b/databind/src/databind/json/tests/converters_test.py @@ -766,9 +766,9 @@ class Child(Parent): assert result == Child(a=1, b="hello") -def test_extra_keys_parent_decorated_child_not_decorated_raises() -> None: - """When only the parent has ExtraKeys(), the child should NOT inherit that permission - via get_class_settings (which uses vars()), so extra keys on the child raise an error.""" +def test_extra_keys_parent_decorated_child_inherits_and_can_override() -> None: + """When only the parent has ExtraKeys(), the child inherits that permission via MRO traversal. + The child can override it by decorating with @ExtraKeys(allow=False).""" mapper = make_mapper([SchemaConverter(), PlainDatatypeConverter()]) @ExtraKeys() @@ -777,10 +777,19 @@ class Parent: a: int @dataclasses.dataclass - class Child(Parent): + class ChildInheriting(Parent): b: str = "" - # Child has no ExtraKeys() of its own; extra keys should cause an error. + @ExtraKeys(allow=False) + @dataclasses.dataclass + class ChildOverriding(Parent): + b: str = "" + + # Child inherits ExtraKeys() from parent, so extra keys are allowed. + result = mapper.deserialize({"a": 1, "b": "hello", "extra": "ignored"}, ChildInheriting) + assert result == ChildInheriting(a=1, b="hello") + + # Child explicitly overrides with ExtraKeys(allow=False), so extra keys raise an error. with pytest.raises(ConversionError) as excinfo: - mapper.deserialize({"a": 1, "b": "hello", "extra": "ignored"}, Child) + mapper.deserialize({"a": 1, "b": "hello", "extra": "ignored"}, ChildOverriding) assert "extra" in str(excinfo.value) From 78c8bccf32161b5464e0a9804ff804d4482c6648 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Wed, 25 Mar 2026 12:21:53 +0100 Subject: [PATCH 4/8] fix: Fix ClassDecoratorSetting and get_class_settings to correctly handle __databind_settings__ on subclasses --- .changelog/_unreleased.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changelog/_unreleased.toml diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml new file mode 100644 index 0000000..e9fc08d --- /dev/null +++ b/.changelog/_unreleased.toml @@ -0,0 +1,5 @@ +[[entries]] +id = "ab7f2766-e6f5-4236-946d-bddedcd73433" +type = "fix" +description = "Fix ClassDecoratorSetting and get_class_settings to correctly handle __databind_settings__ on subclasses" +author = "@NiklasRosenstein" From 388f306637fedae2312945644ec5d7b560e2e8e7 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Wed, 25 Mar 2026 12:42:07 +0100 Subject: [PATCH 5/8] fix: Update GitHub Actions runner and action versions Ubuntu 20.04 runners have been removed, causing jobs to queue forever. Update to ubuntu-latest and bump checkout/setup-python to v4/v5. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/python.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index 822f1f7..7a4e76e 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -6,25 +6,25 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.x"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: NiklasRosenstein/slap@gha/install/v1 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v5 with: { python-version: "${{ matrix.python-version }}" } - run: slap install --link --no-venv-check - run: slap test - run: slap publish --dry documentation: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: NiklasRosenstein/slap@gha/install/v1 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v5 with: { python-version: "3.10" } - run: slap --version && slap install --only-extras docs --no-venv-check - run: slap run --no-venv-check docs:build From 5d1708381dfa8683be2e3e0b93030e2359b97a5d Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Wed, 25 Mar 2026 12:47:59 +0100 Subject: [PATCH 6/8] fix: Suppress mypy errors on annotated enum member in test The Alias annotation on enum members is intentional for databind but violates mypy's enum typing spec. Add type: ignore to silence it. Co-Authored-By: Claude Opus 4.6 --- databind/src/databind/json/tests/converters_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/databind/src/databind/json/tests/converters_test.py b/databind/src/databind/json/tests/converters_test.py index 1e9e77e..fe14e4d 100644 --- a/databind/src/databind/json/tests/converters_test.py +++ b/databind/src/databind/json/tests/converters_test.py @@ -106,7 +106,7 @@ def test_enum_converter(direction: Direction) -> None: class Pet(enum.Enum): CAT = enum.auto() DOG = enum.auto() - LION: te.Annotated[int, Alias("KITTY")] = enum.auto() + LION: te.Annotated[int, Alias("KITTY")] = enum.auto() # type: ignore[misc,assignment] if direction == Direction.SERIALIZE: assert mapper.convert(direction, Pet.CAT, Pet) == "CAT" From 97459b63554682b3eb49a88292b7028434e55b4d Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Wed, 25 Mar 2026 12:50:49 +0100 Subject: [PATCH 7/8] fix: Suppress mypy errors for pkg_resources import on Python 3.12+ pkg_resources has no stubs and is only imported on Python < 3.10 at runtime, but mypy still checks the branch. Suppress import-not-found and the resulting no-any-return error. Co-Authored-By: Claude Opus 4.6 --- databind/src/databind/core/union.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/databind/src/databind/core/union.py b/databind/src/databind/core/union.py index 95bb90d..27f8eee 100644 --- a/databind/src/databind/core/union.py +++ b/databind/src/databind/core/union.py @@ -12,7 +12,7 @@ from databind.core.utils import T if sys.version_info[:2] < (3, 10): - from pkg_resources import EntryPoint, iter_entry_points + from pkg_resources import EntryPoint, iter_entry_points # type: ignore[import-not-found] else: from importlib.metadata import EntryPoint, entry_points @@ -148,7 +148,7 @@ def _entrypoints(self) -> t.Dict[str, EntryPoint]: def get_type_id(self, type_: t.Any) -> str: for ep in self._entrypoints.values(): if ep.load() == type_: - return ep.name + return ep.name # type: ignore[no-any-return] raise ValueError(f"unable to resolve type {type_!r} to a type ID for {self}") def get_type_by_id(self, type_id: str) -> t.Any: From 22fe4e21871e03acf2c6a2b2e42dc4566db8e694 Mon Sep 17 00:00:00 2001 From: Niklas Rosenstein Date: Wed, 25 Mar 2026 12:54:46 +0100 Subject: [PATCH 8/8] fix: Add unused-ignore to type ignores for cross-version compat The pkg_resources type ignores are needed on 3.12+ but unused on 3.11 where stubs are available. Adding unused-ignore suppresses the warning on versions where the ignore isn't needed. Co-Authored-By: Claude Opus 4.6 --- databind/src/databind/core/union.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/databind/src/databind/core/union.py b/databind/src/databind/core/union.py index 27f8eee..e39f53e 100644 --- a/databind/src/databind/core/union.py +++ b/databind/src/databind/core/union.py @@ -12,7 +12,7 @@ from databind.core.utils import T if sys.version_info[:2] < (3, 10): - from pkg_resources import EntryPoint, iter_entry_points # type: ignore[import-not-found] + from pkg_resources import EntryPoint, iter_entry_points # type: ignore[import-not-found,unused-ignore] else: from importlib.metadata import EntryPoint, entry_points @@ -148,7 +148,7 @@ def _entrypoints(self) -> t.Dict[str, EntryPoint]: def get_type_id(self, type_: t.Any) -> str: for ep in self._entrypoints.values(): if ep.load() == type_: - return ep.name # type: ignore[no-any-return] + return ep.name # type: ignore[no-any-return,unused-ignore] raise ValueError(f"unable to resolve type {type_!r} to a type ID for {self}") def get_type_by_id(self, type_id: str) -> t.Any: