Repository: python-attrs/attrs
Pull request #916 (2022-01-30 14:50:59)

Added attrs.validators.min_len()
# Summary

<!-- Please tell us what your pull request is about here. -->


# 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.
-->

- [ ] Added **tests** for changed code.
  Our CI fails if coverage is not 100%.
- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py).
- [ ] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``).
    - [ ] ...and used in the stub test file `tests/typing_example.py`.
    - [ ] If they've been added to `attr/__init__.pyi`, they've *also* been re-imported in `attrs/__init__.pyi`.
- [ ] Updated **documentation** for changed code.
    - [ ] New functions/classes have to be added to `docs/api.rst` by hand.
    - [ ] Changes to the signature of `@attr.s()` have to be added by hand too.
    - [ ] 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.
- [ ] Documentation in `.rst` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/).
- [ ] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/main/changelog.d).
- [ ] 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!
-->


hynek: thanks!

Patch:
diff --git a/changelog.d/916.change.rst b/changelog.d/916.change.rst
new file mode 100644
index 0000000..4a5ccd2
--- /dev/null
+++ b/changelog.d/916.change.rst
@@ -0,0 +1,1 @@
+Added ``attrs.validators.min_len()``.
diff --git a/docs/api.rst b/docs/api.rst
index 06c5a80..a60a6e0 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -524,6 +524,22 @@ All objects from ``attrs.validators`` are also available from ``attr.validators`
          ...
       ValueError: ("Length of 'x' must be <= 4: 5")
 
+.. autofunction:: attrs.validators.min_len
+
+   For example:
+
+   .. doctest::
+
+      >>> @attrs.define
+      ... class C:
+      ...     x = attrs.field(validator=attrs.validators.min_len(1))
+      >>> C("bacon")
+      C(x='bacon')
+      >>> C("")
+      Traceback (most recent call last):
+         ...
+      ValueError: ("Length of 'x' must be => 1: 0")
+
 .. autofunction:: attrs.validators.instance_of
 
    For example:
diff --git a/src/attr/validators.py b/src/attr/validators.py
index 0b0c834..7e3ff16 100644
--- a/src/attr/validators.py
+++ b/src/attr/validators.py
@@ -37,6 +37,7 @@ __all__ = [
     "lt",
     "matches_re",
     "max_len",
+    "min_len",
     "optional",
     "provides",
     "set_disabled",
@@ -559,3 +560,34 @@ def max_len(length):
     .. versionadded:: 21.3.0
     """
     return _MaxLengthValidator(length)
+
+
+@attrs(repr=False, frozen=True, slots=True)
+class _MinLengthValidator(object):
+    min_length = attrib()
+
+    def __call__(self, inst, attr, value):
+        """
+        We use a callable class to be able to change the ``__repr__``.
+        """
+        if len(value) < self.min_length:
+            raise ValueError(
+                "Length of '{name}' must be => {min}: {len}".format(
+                    name=attr.name, min=self.min_length, len=len(value)
+                )
+            )
+
+    def __repr__(self):
+        return "<min_len validator for {min}>".format(min=self.min_length)
+
+
+def min_len(length):
+    """
+    A validator that raises `ValueError` if the initializer is called
+    with a string or iterable that is shorter than *length*.
+
+    :param int length: Minimum length of the string or iterable
+
+    .. versionadded:: 22.1.0
+    """
+    return _MinLengthValidator(length)
diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi
index 5e00b85..81b9910 100644
--- a/src/attr/validators.pyi
+++ b/src/attr/validators.pyi
@@ -76,3 +76,4 @@ def le(val: _T) -> _ValidatorType[_T]: ...
 def ge(val: _T) -> _ValidatorType[_T]: ...
 def gt(val: _T) -> _ValidatorType[_T]: ...
 def max_len(length: int) -> _ValidatorType[_T]: ...
+def min_len(length: int) -> _ValidatorType[_T]: ...


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 38c39f3..69ec164 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -28,6 +28,7 @@ from attr.validators import (
     lt,
     matches_re,
     max_len,
+    min_len,
     optional,
     provides,
 )
@@ -950,3 +951,74 @@ class TestMaxLen:
         __repr__ is meaningful.
         """
         assert repr(max_len(23)) == "<max_len validator for 23>"
+
+
+class TestMinLen:
+    """
+    Tests for `min_len`.
+    """
+
+    MIN_LENGTH = 2
+
+    def test_in_all(self):
+        """
+        validator is in ``__all__``.
+        """
+        assert min_len.__name__ in validator_module.__all__
+
+    def test_retrieve_min_len(self):
+        """
+        The configured min. length can be extracted from the Attribute
+        """
+
+        @attr.s
+        class Tester(object):
+            value = attr.ib(validator=min_len(self.MIN_LENGTH))
+
+        assert fields(Tester).value.validator.min_length == self.MIN_LENGTH
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            "foo",
+            "spam",
+            list(range(MIN_LENGTH)),
+            {"spam": 3, "eggs": 4},
+        ],
+    )
+    def test_check_valid(self, value):
+        """
+        Silent if len(value) => min_len.
+        Values can be strings and other iterables.
+        """
+
+        @attr.s
+        class Tester(object):
+            value = attr.ib(validator=min_len(self.MIN_LENGTH))
+
+        Tester(value)  # shouldn't raise exceptions
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            "",
+            list(range(1)),
+        ],
+    )
+    def test_check_invalid(self, value):
+        """
+        Raise ValueError if len(value) < min_len.
+        """
+
+        @attr.s
+        class Tester(object):
+            value = attr.ib(validator=min_len(self.MIN_LENGTH))
+
+        with pytest.raises(ValueError):
+            Tester(value)
+
+    def test_repr(self):
+        """
+        __repr__ is meaningful.
+        """
+        assert repr(min_len(23)) == "<min_len validator for 23>"