Skip to content

Commit cbaef3f

Browse files
authored
Make validators optional in deep_mapping (#1448)
* Make validators optional in deep_mapping Fixes #1246 * Add news fragment
1 parent 6879ffd commit cbaef3f

File tree

5 files changed

+82
-9
lines changed

5 files changed

+82
-9
lines changed

changelog.d/1448.change.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`attrs.validators.deep_mapping()` now allows to leave out either *key_validator* xor *value_validator*.

src/attr/validators.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,9 @@ def deep_iterable(member_validator, iterable_validator=None):
378378

379379
@attrs(repr=False, slots=True, unsafe_hash=True)
380380
class _DeepMapping:
381-
key_validator = attrib(validator=is_callable())
382-
value_validator = attrib(validator=is_callable())
383-
mapping_validator = attrib(default=None, validator=optional(is_callable()))
381+
key_validator = attrib(validator=optional(is_callable()))
382+
value_validator = attrib(validator=optional(is_callable()))
383+
mapping_validator = attrib(validator=optional(is_callable()))
384384

385385
def __call__(self, inst, attr, value):
386386
"""
@@ -390,30 +390,51 @@ def __call__(self, inst, attr, value):
390390
self.mapping_validator(inst, attr, value)
391391

392392
for key in value:
393-
self.key_validator(inst, attr, key)
394-
self.value_validator(inst, attr, value[key])
393+
if self.key_validator is not None:
394+
self.key_validator(inst, attr, key)
395+
if self.value_validator is not None:
396+
self.value_validator(inst, attr, value[key])
395397

396398
def __repr__(self):
397399
return f"<deep_mapping validator for objects mapping {self.key_validator!r} to {self.value_validator!r}>"
398400

399401

400-
def deep_mapping(key_validator, value_validator, mapping_validator=None):
402+
def deep_mapping(
403+
key_validator=None, value_validator=None, mapping_validator=None
404+
):
401405
"""
402406
A validator that performs deep validation of a dictionary.
403407
408+
All validators are optional, but at least one of *key_validator* or
409+
*value_validator* must be provided.
410+
404411
Args:
405412
key_validator: Validator to apply to dictionary keys.
406413
407414
value_validator: Validator to apply to dictionary values.
408415
409416
mapping_validator:
410-
Validator to apply to top-level mapping attribute (optional).
417+
Validator to apply to top-level mapping attribute.
411418
412419
.. versionadded:: 19.1.0
413420
421+
.. versionchanged:: 25.4.0
422+
*key_validator* and *value_validator* are now optional, but at least one
423+
of them must be provided.
424+
414425
Raises:
415-
TypeError: if any sub-validators fail
426+
TypeError: If any sub-validator fails on validation.
427+
428+
ValueError:
429+
If neither *key_validator* nor *value_validator* is provided on
430+
instantiation.
416431
"""
432+
if key_validator is None and value_validator is None:
433+
msg = (
434+
"At least one of key_validator or value_validator must be provided"
435+
)
436+
raise ValueError(msg)
437+
417438
return _DeepMapping(key_validator, value_validator, mapping_validator)
418439

419440

src/attr/validators.pyi

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,16 @@ def deep_iterable(
6565
member_validator: _ValidatorArgType[_T],
6666
iterable_validator: _ValidatorType[_I] | None = ...,
6767
) -> _ValidatorType[_I]: ...
68+
@overload
6869
def deep_mapping(
6970
key_validator: _ValidatorType[_K],
70-
value_validator: _ValidatorType[_V],
71+
value_validator: _ValidatorType[_V] | None = ...,
72+
mapping_validator: _ValidatorType[_M] | None = ...,
73+
) -> _ValidatorType[_M]: ...
74+
@overload
75+
def deep_mapping(
76+
key_validator: _ValidatorType[_K] | None = ...,
77+
value_validator: _ValidatorType[_V] = ...,
7178
mapping_validator: _ValidatorType[_M] | None = ...,
7279
) -> _ValidatorType[_M]: ...
7380
def is_callable() -> _ValidatorType[_T]: ...

tests/test_validators.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,8 @@ def test_success(self):
651651
(instance_of(str), instance_of(int), 42),
652652
(42, 42, None),
653653
(42, 42, 42),
654+
(42, None, None),
655+
(None, 42, None),
654656
],
655657
)
656658
def test_noncallable_validators(
@@ -719,8 +721,40 @@ def test_repr(self):
719721
"<deep_mapping validator for objects mapping "
720722
f"{key_repr} to {value_repr}>"
721723
)
724+
722725
assert expected_repr == repr(v)
723726

727+
def test_error_neither_validator_provided(self):
728+
"""
729+
Raise ValueError if neither key_validator nor value_validator is
730+
provided.
731+
"""
732+
with pytest.raises(ValueError) as e:
733+
deep_mapping()
734+
735+
assert (
736+
"At least one of key_validator or value_validator must be provided"
737+
== e.value.args[0]
738+
)
739+
740+
def test_key_validator_can_be_none(self):
741+
"""
742+
The key validator can be None.
743+
"""
744+
v = deep_mapping(value_validator=instance_of(int))
745+
a = simple_attr("test")
746+
747+
v(None, a, {"a": 6, "b": 7})
748+
749+
def test_value_validator_can_be_none(self):
750+
"""
751+
The value validator can be None.
752+
"""
753+
v = deep_mapping(key_validator=instance_of(str))
754+
a = simple_attr("test")
755+
756+
v(None, a, {"a": 6, "b": 7})
757+
724758

725759
class TestIsCallable:
726760
"""

tests/typing_example.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,16 @@ class Validated:
216216
attr.validators.instance_of(C), attr.validators.instance_of(D)
217217
),
218218
)
219+
d2 = attr.ib(
220+
type=Dict[C, D],
221+
validator=attr.validators.deep_mapping(attr.validators.instance_of(C)),
222+
)
223+
d3 = attr.ib(
224+
type=Dict[C, D],
225+
validator=attr.validators.deep_mapping(
226+
value_validator=attr.validators.instance_of(C)
227+
),
228+
)
219229
e: str = attr.ib(validator=attr.validators.matches_re(re.compile(r"foo")))
220230
f: str = attr.ib(
221231
validator=attr.validators.matches_re(r"foo", flags=42, func=re.search)

0 commit comments

Comments
 (0)