From 1a42733c6c88904ebe8dbdbd0d67b0b2c59823cb Mon Sep 17 00:00:00 2001 From: sap1110 <238814652+sap1110@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:39:26 +0530 Subject: [PATCH] Name the offending key when per_key_traits rejects a value A Dict configured with per_key_traits reported failures against the trait as a whole, so the message named the trait but not the key that was actually invalid: Values of the 'bar' trait of a Foo instance must be a dict, but a value of False was specified. With several per-key traits configured, that leaves the caller to work out which key the message is about. Failures routed through a per_key_traits entry now name it: Value at key 'that' of the 'bar' trait of a Foo instance must be a dict, but a value of False was specified. Only the per-key path changes. A uniform value_trait failure still reports against the trait as a whole, and there is a test covering that. --- tests/test_traitlets.py | 29 +++++++++++++++++++++++++++++ traitlets/traitlets.py | 12 ++++++++++++ 2 files changed, 41 insertions(+) diff --git a/tests/test_traitlets.py b/tests/test_traitlets.py index 2e7b8459..19ab60ef 100644 --- a/tests/test_traitlets.py +++ b/tests/test_traitlets.py @@ -1961,6 +1961,35 @@ class TestInstanceFullyValidatedDict(TraitTestBase): _bad_values = [{"foo": 0, "bar": 1}, {"foo": "0", "bar": "1"}, {"foo": 0, 0: "1"}] +def test_dict_per_key_traits_error_names_key(): + """A `per_key_traits` failure should say which key was rejected.""" + + class Foo(HasTraits): + bar = Dict(per_key_traits={"this": Unicode(), "that": Int()}) + + with pytest.raises(TraitError) as excinfo: + Foo().bar = {"this": "ok", "that": "not an int"} + + message = str(excinfo.value) + assert "at key 'that'" in message + assert "'bar' trait" in message + assert "an int" in message + + +def test_dict_value_trait_error_does_not_name_a_key(): + """A uniform `value_trait` failure still reports against the trait as a whole.""" + + class Foo(HasTraits): + bar = Dict(value_trait=Int()) + + with pytest.raises(TraitError) as excinfo: + Foo().bar = {"this": "not an int"} + + message = str(excinfo.value) + assert message.startswith("Values of the 'bar' trait") + assert "at key" not in message + + def test_dict_default_value(): """Check that the `{}` default value of the Dict traitlet constructor is actually copied.""" diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py index 0989ea98..31eb06e0 100644 --- a/traitlets/traitlets.py +++ b/traitlets/traitlets.py @@ -3995,6 +3995,16 @@ def element_error( ) raise TraitError(e) + def per_key_element_error( + self, obj: t.Any, key: t.Any, element: t.Any, validator: t.Any + ) -> None: + """Raise a TraitError naming the key whose `per_key_traits` entry rejected a value.""" + e = ( + f"Value at key {key!r} of the '{self.name}' trait of {class_of(obj)} instance" + f" must be {validator.info()}, but a value of {repr_type(element)} was specified." + ) + raise TraitError(e) + def validate(self, obj: t.Any, value: t.Any) -> dict[K, V] | None: value = super().validate(obj, value) if value is None: @@ -4020,6 +4030,8 @@ def validate_elements(self, obj: t.Any, value: dict[t.Any, t.Any]) -> dict[K, V] try: v = active_value_trait._validate(obj, v) except TraitError: + if key in per_key_override: + self.per_key_element_error(obj, key, v, active_value_trait) self.element_error(obj, v, active_value_trait, "Values") validated[key] = v