Repository: python-attrs/attrs
Pull request #925 (2022-03-04 03:43:15)

Allow `deep_iterable` member validator to accept a list of validators
# Summary

<!-- Please tell us what your pull request is about here. -->
Proposed fix for #924 
Allows users to pass in a list of validators within the member validator of the `deep_iterable` validator. I feel this makes it more consistent with what users would expect. I atleast had a minor use case when using `attrs` and thought it would be worthwhile to support this.

This PR contains:
- Small tweak to the `deep_iterable` validator
- Updates to the doc string of `deep_iterable`
- Tests for the new behaviour (backward compatible)
- Minor doc string fix in a different test

If people think this is useful I would be happy to make a different PR to support the same for the `deep_mapping` validator




# Pull Request Check List

<!--
This is just a friendly reminder about the most common mistakes.
Please make sure that you tick all boxes.
But please read our [contribution guide](https://github.com/python-attrs/attrs/blob/main/.github/CONTRIBUTING.md) at least once, it will save you unnecessary review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing left to do.
If your pull request is a documentation fix or a trivial typo, feel free to delete the whole thing.
-->

- [x] Added **tests** for changed code.
  Our CI fails if coverage is not 100%.
- [x] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py).
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [x] ...and used in the stub test file `tests/typing_example.py`.
    - [x] If they've been added to `attr/__init__.pyi`, they've *also* been re-imported in `attrs/__init__.pyi`.
- [x] Updated **documentation** for changed code.
    - [x] New functions/classes have to be added to `docs/api.rst` by hand.
    - [x] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded).
          Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/main/src/attr/__init__.py) file.
- [x] Documentation in `.rst` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [x] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/main/changelog.d).
- [x] Consider granting [push permissions to the PR branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork), so maintainers can fix minor issues themselves without pestering you.

<!--
If you have *any* questions to *any* of the points above, just **submit and ask**!
This checklist is here to *help* you, not to deter you from contributing!
-->


vedantpuri: @hynek / @Tinche Thoughts on this?
hynek: Sorry for the delay Vedant; I’ll get to it. 
vedantpuri: > Sorry for the delay Vedant; I’ll get to it. 

Sounds good, Thankyou!
vedantpuri: @hynek Thanks for your comments, I added in the Changelog and responded to PR comments!
vedantpuri: @hynek Responded to your comments, Hopefully CI should pass this time (tried running tox locally with py39 and the issue seems to be resolved)

Might be worth changing the repo workflow to allow the CI to run on PRs ?
hynek: > Might be worth changing the repo workflow to allow the CI to run on PRs ?

It totally does; the problem is that crypto grifters started abusing free CIs for mining, so now the first PRs for a user on each project are restricted.  :-/

Patch:
diff --git a/changelog.d/925.change.rst b/changelog.d/925.change.rst
new file mode 100644
index 0000000..ddc7952
--- /dev/null
+++ b/changelog.d/925.change.rst
@@ -0,0 +1,1 @@
+``attrs.validators.deep_iterable()``'s *member_validator* argument now also accepts a list of validators and wraps them in an ``attrs.validators.and_()``.
diff --git a/src/attr/validators.py b/src/attr/validators.py
index 7e3ff16..5f850cc 100644
--- a/src/attr/validators.py
+++ b/src/attr/validators.py
@@ -407,7 +407,7 @@ def deep_iterable(member_validator, iterable_validator=None):
     """
     A validator that performs deep validation of an iterable.
 
-    :param member_validator: Validator to apply to iterable members
+    :param member_validator: Validator(s) to apply to iterable members
     :param iterable_validator: Validator to apply to iterable itself
         (optional)
 
@@ -415,6 +415,8 @@ def deep_iterable(member_validator, iterable_validator=None):
 
     :raises TypeError: if any sub-validators fail
     """
+    if isinstance(member_validator, (list, tuple)):
+        member_validator = and_(*member_validator)
     return _DeepIterable(member_validator, iterable_validator)
 
 
diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi
index 81b9910..54b9dba 100644
--- a/src/attr/validators.pyi
+++ b/src/attr/validators.pyi
@@ -18,6 +18,7 @@ from typing import (
 )
 
 from . import _ValidatorType
+from . import _ValidatorArgType
 
 _T = TypeVar("_T")
 _T1 = TypeVar("_T1")
@@ -62,7 +63,7 @@ def matches_re(
     ] = ...,
 ) -> _ValidatorType[AnyStr]: ...
 def deep_iterable(
-    member_validator: _ValidatorType[_T],
+    member_validator: _ValidatorArgType[_T],
     iterable_validator: Optional[_ValidatorType[_I]] = ...,
 ) -> _ValidatorType[_I]: ...
 def deep_mapping(


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 69ec164..fce774b 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -504,6 +504,22 @@ class TestIn_(object):
         assert (("<in_ validator with options [3, 4, 5]>")) == repr(v)
 
 
+@pytest.fixture(
+    name="member_validator",
+    params=(
+        instance_of(int),
+        [always_pass, instance_of(int)],
+        (always_pass, instance_of(int)),
+    ),
+    scope="module",
+)
+def _member_validator(request):
+    """
+    Provides sample `member_validator`s for some tests in `TestDeepIterable`
+    """
+    return request.param
+
+
 class TestDeepIterable(object):
     """
     Tests for `deep_iterable`.
@@ -515,21 +531,19 @@ class TestDeepIterable(object):
         """
         assert deep_iterable.__name__ in validator_module.__all__
 
-    def test_success_member_only(self):
+    def test_success_member_only(self, member_validator):
         """
         If the member validator succeeds and the iterable validator is not set,
         nothing happens.
         """
-        member_validator = instance_of(int)
         v = deep_iterable(member_validator)
         a = simple_attr("test")
         v(None, a, [42])
 
-    def test_success_member_and_iterable(self):
+    def test_success_member_and_iterable(self, member_validator):
         """
         If both the member and iterable validators succeed, nothing happens.
         """
-        member_validator = instance_of(int)
         iterable_validator = instance_of(list)
         v = deep_iterable(member_validator, iterable_validator)
         a = simple_attr("test")
@@ -542,6 +556,8 @@ class TestDeepIterable(object):
             (42, instance_of(list)),
             (42, 42),
             (42, None),
+            ([instance_of(int), 42], 42),
+            ([42, instance_of(int)], 42),
         ),
     )
     def test_noncallable_validators(
@@ -562,17 +578,16 @@ class TestDeepIterable(object):
         assert message in e.value.msg
         assert value == e.value.value
 
-    def test_fail_invalid_member(self):
+    def test_fail_invalid_member(self, member_validator):
         """
         Raise member validator error if an invalid member is found.
         """
-        member_validator = instance_of(int)
         v = deep_iterable(member_validator)
         a = simple_attr("test")
         with pytest.raises(TypeError):
             v(None, a, [42, "42"])
 
-    def test_fail_invalid_iterable(self):
+    def test_fail_invalid_iterable(self, member_validator):
         """
         Raise iterable validator error if an invalid iterable is found.
         """
@@ -583,12 +598,11 @@ class TestDeepIterable(object):
         with pytest.raises(TypeError):
             v(None, a, [42])
 
-    def test_fail_invalid_member_and_iterable(self):
+    def test_fail_invalid_member_and_iterable(self, member_validator):
         """
         Raise iterable validator error if both the iterable
         and a member are invalid.
         """
-        member_validator = instance_of(int)
         iterable_validator = instance_of(tuple)
         v = deep_iterable(member_validator, iterable_validator)
         a = simple_attr("test")
@@ -608,7 +622,24 @@ class TestDeepIterable(object):
         expected_repr = (
             "<deep_iterable validator for iterables of {member_repr}>"
         ).format(member_repr=member_repr)
-        assert ((expected_repr)) == repr(v)
+        assert expected_repr == repr(v)
+
+    def test_repr_member_only_sequence(self):
+        """
+        Returned validator has a useful `__repr__`
+        when only member validator is set and the member validator is a list of
+        validators
+        """
+        member_validator = [always_pass, instance_of(int)]
+        member_repr = (
+            "_AndValidator(_validators=({func}, "
+            "<instance_of validator for type <{type} 'int'>>))"
+        ).format(func=repr(always_pass), type=TYPE)
+        v = deep_iterable(member_validator)
+        expected_repr = (
+            "<deep_iterable validator for iterables of {member_repr}>"
+        ).format(member_repr=member_repr)
+        assert expected_repr == repr(v)
 
     def test_repr_member_and_iterable(self):
         """
@@ -630,6 +661,29 @@ class TestDeepIterable(object):
         ).format(iterable_repr=iterable_repr, member_repr=member_repr)
         assert expected_repr == repr(v)
 
+    def test_repr_sequence_member_and_iterable(self):
+        """
+        Returned validator has a useful `__repr__` when both member
+        and iterable validators are set and the member validator is a list of
+        validators
+        """
+        member_validator = [always_pass, instance_of(int)]
+        member_repr = (
+            "_AndValidator(_validators=({func}, "
+            "<instance_of validator for type <{type} 'int'>>))"
+        ).format(func=repr(always_pass), type=TYPE)
+        iterable_validator = instance_of(list)
+        iterable_repr = (
+            "<instance_of validator for type <{type} 'list'>>"
+        ).format(type=TYPE)
+        v = deep_iterable(member_validator, iterable_validator)
+        expected_repr = (
+            "<deep_iterable validator for"
+            " {iterable_repr} iterables of {member_repr}>"
+        ).format(iterable_repr=iterable_repr, member_repr=member_repr)
+
+        assert expected_repr == repr(v)
+
 
 class TestDeepMapping(object):
     """
@@ -804,7 +858,7 @@ def test_hashability():
 
 class TestLtLeGeGt:
     """
-    Tests for `max_len`.
+    Tests for `Lt, Le, Ge, Gt`.
     """
 
     BOUND = 4