From 14ef76bac0a04a0a257094964cc0bd7d6877eb4c Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sun, 27 Jul 2025 13:20:47 +0200 Subject: [PATCH 1/2] Make validators optional in deep_mapping Fixes #1246 --- src/attr/validators.py | 37 +++++++++++++++++++++++++++++-------- src/attr/validators.pyi | 9 ++++++++- tests/test_validators.py | 34 ++++++++++++++++++++++++++++++++++ tests/typing_example.py | 10 ++++++++++ 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/attr/validators.py b/src/attr/validators.py index 15595576d..3408abef5 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -378,9 +378,9 @@ def deep_iterable(member_validator, iterable_validator=None): @attrs(repr=False, slots=True, unsafe_hash=True) class _DeepMapping: - key_validator = attrib(validator=is_callable()) - value_validator = attrib(validator=is_callable()) - mapping_validator = attrib(default=None, validator=optional(is_callable())) + key_validator = attrib(validator=optional(is_callable())) + value_validator = attrib(validator=optional(is_callable())) + mapping_validator = attrib(validator=optional(is_callable())) def __call__(self, inst, attr, value): """ @@ -390,30 +390,51 @@ def __call__(self, inst, attr, value): self.mapping_validator(inst, attr, value) for key in value: - self.key_validator(inst, attr, key) - self.value_validator(inst, attr, value[key]) + if self.key_validator is not None: + self.key_validator(inst, attr, key) + if self.value_validator is not None: + self.value_validator(inst, attr, value[key]) def __repr__(self): return f"" -def deep_mapping(key_validator, value_validator, mapping_validator=None): +def deep_mapping( + key_validator=None, value_validator=None, mapping_validator=None +): """ A validator that performs deep validation of a dictionary. + All validators are optional, but at least one of *key_validator* or + *value_validator* must be provided. + Args: key_validator: Validator to apply to dictionary keys. value_validator: Validator to apply to dictionary values. mapping_validator: - Validator to apply to top-level mapping attribute (optional). + Validator to apply to top-level mapping attribute. .. versionadded:: 19.1.0 + .. versionchanged:: 25.4.0 + *key_validator* and *value_validator* are now optional, but at least one + of them must be provided. + Raises: - TypeError: if any sub-validators fail + TypeError: If any sub-validator fails on validation. + + ValueError: + If neither *key_validator* nor *value_validator* is provided on + instantiation. """ + if key_validator is None and value_validator is None: + msg = ( + "At least one of key_validator or value_validator must be provided" + ) + raise ValueError(msg) + return _DeepMapping(key_validator, value_validator, mapping_validator) diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi index a0fdda7c8..94ea31fff 100644 --- a/src/attr/validators.pyi +++ b/src/attr/validators.pyi @@ -65,9 +65,16 @@ def deep_iterable( member_validator: _ValidatorArgType[_T], iterable_validator: _ValidatorType[_I] | None = ..., ) -> _ValidatorType[_I]: ... +@overload def deep_mapping( key_validator: _ValidatorType[_K], - value_validator: _ValidatorType[_V], + value_validator: _ValidatorType[_V] | None = ..., + mapping_validator: _ValidatorType[_M] | None = ..., +) -> _ValidatorType[_M]: ... +@overload +def deep_mapping( + key_validator: _ValidatorType[_K] | None = ..., + value_validator: _ValidatorType[_V] = ..., mapping_validator: _ValidatorType[_M] | None = ..., ) -> _ValidatorType[_M]: ... def is_callable() -> _ValidatorType[_T]: ... diff --git a/tests/test_validators.py b/tests/test_validators.py index 45e7050d3..df94d2aa6 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -651,6 +651,8 @@ def test_success(self): (instance_of(str), instance_of(int), 42), (42, 42, None), (42, 42, 42), + (42, None, None), + (None, 42, None), ], ) def test_noncallable_validators( @@ -719,8 +721,40 @@ def test_repr(self): "" ) + assert expected_repr == repr(v) + def test_error_neither_validator_provided(self): + """ + Raise ValueError if neither key_validator nor value_validator is + provided. + """ + with pytest.raises(ValueError) as e: + deep_mapping() + + assert ( + "At least one of key_validator or value_validator must be provided" + == e.value.args[0] + ) + + def test_key_validator_can_be_none(self): + """ + The key validator can be None. + """ + v = deep_mapping(value_validator=instance_of(int)) + a = simple_attr("test") + + v(None, a, {"a": 6, "b": 7}) + + def test_value_validator_can_be_none(self): + """ + The value validator can be None. + """ + v = deep_mapping(key_validator=instance_of(str)) + a = simple_attr("test") + + v(None, a, {"a": 6, "b": 7}) + class TestIsCallable: """ diff --git a/tests/typing_example.py b/tests/typing_example.py index 82a5c253b..e5738aa77 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -216,6 +216,16 @@ class Validated: attr.validators.instance_of(C), attr.validators.instance_of(D) ), ) + d2 = attr.ib( + type=Dict[C, D], + validator=attr.validators.deep_mapping(attr.validators.instance_of(C)), + ) + d3 = attr.ib( + type=Dict[C, D], + validator=attr.validators.deep_mapping( + value_validator=attr.validators.instance_of(C) + ), + ) e: str = attr.ib(validator=attr.validators.matches_re(re.compile(r"foo"))) f: str = attr.ib( validator=attr.validators.matches_re(r"foo", flags=42, func=re.search) From afe5cfc262f93f218c21ae3f13b7d860670f0259 Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Sun, 27 Jul 2025 15:00:03 +0200 Subject: [PATCH 2/2] Add news fragment --- changelog.d/1448.change.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1448.change.md diff --git a/changelog.d/1448.change.md b/changelog.d/1448.change.md new file mode 100644 index 000000000..11a1e39a8 --- /dev/null +++ b/changelog.d/1448.change.md @@ -0,0 +1 @@ +`attrs.validators.deep_mapping()` now allows to leave out either *key_validator* xor *value_validator*.