Repository: python-attrs/attrs

Issue:
Optional inner validators for `deep_mapping`
This is a copy of the fourth issue collected in https://github.com/python-attrs/attrs/issues/1206 to enable separate tracking of the bug.

### The issue
For `deep_iterable`, only the `iterable_validator` is optional, which makes sense because if no `member_validator` was provided, one should simply use a regular validator in the first place. However, for `deep_mapping`, both `value_validator` and `key_validator` are required. This makes its use unnecessarily complicated in certain situations. For example, validating only dictionary keys requires a rather cumbersome workaround and also results in unnecessary calls of the `value_validator`:
```python
@define
class B:
    x: Dict[str, Any] = field(
        validator=deep_mapping(
            key_validator=and_(instance_of(str), min_len(1)),
            value_validator=lambda *x: None,
        )
    )
```
Instead, I guess the user-friendly way would be to check that **at least one of them** is provided, but not necessarily both.


Discussion:

Patch:
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,1 @@
+`attrs.validators.deep_mapping()` now allows to leave out either *key_validator* xor *value_validator*.
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"<deep_mapping validator for objects mapping {self.key_validator!r} to {self.value_validator!r}>"
 
 
-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]: ...


Test patch:
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):
             "<deep_mapping validator for objects mapping "
             f"{key_repr} to {value_repr}>"
         )
+
         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)