Repository: python-attrs/attrs
Pull request #552 (2019-07-12 14:15:55)

adding matches_re validator
still needs more work just getting the ball rolling

# Pull Request Check List

This is just a reminder about the most common mistakes.  Please make sure that you tick all *appropriate* boxes.  But please read our [contribution guide](https://www.attrs.org/en/latest/contributing.html) 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 to do.

- [*] Added **tests** for changed code.
- [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/master/tests/strategies.py).
- [x] 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`.
- [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).
- [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/master/changelog.d).

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!


kurtbrose: thanks for the comments, will address soon

sorry for the slowness, I've been a bit hung up on getting the local dev environment set up, I keep running into this:

```
> py.exe -m pip install -e .
Obtaining file:///C:/Users/Kurt/workspace/attrs
  Installing build dependencies ... done
  Getting requirements to build wheel ... error
  Complete output from command C:\Users\Kurt\AppData\Local\Programs\Python\Python37-32\python.exe C:\Users\Kurt\AppData\Local\Programs\Python\Python37-32\lib\site-packages\pip\_vendor\pep517\_in_process.py get_requires_for_build_wheel C:\Users\Kurt\AppData\Local\Temp\tmp2j45vhxz:
  Traceback (most recent call last):
    File "C:\Users\Kurt\AppData\Local\Programs\Python\Python37-32\lib\site-packages\pip\_vendor\pep517\_in_process.py", line 207, in <module>
      main()
    File "C:\Users\Kurt\AppData\Local\Programs\Python\Python37-32\lib\site-packages\pip\_vendor\pep517\_in_process.py", line 197, in main
      json_out['return_val'] = hook(**hook_input['kwargs'])
    File "C:\Users\Kurt\AppData\Local\Programs\Python\Python37-32\lib\site-packages\pip\_vendor\pep517\_in_process.py", line 54, in get_requires_for_build_wheel
      return hook(config_settings)
    File "C:\Users\Kurt\AppData\Local\Temp\pip-build-env-m4ck65jz\overlay\Lib\site-packages\setuptools\build_meta.py", line 145, in get_requires_for_build_wheel
      return self._get_build_requires(config_settings, requirements=['wheel'])
    File "C:\Users\Kurt\AppData\Local\Temp\pip-build-env-m4ck65jz\overlay\Lib\site-packages\setuptools\build_meta.py", line 126, in _get_build_requires
      self.run_setup()
    File "C:\Users\Kurt\AppData\Local\Temp\pip-build-env-m4ck65jz\overlay\Lib\site-packages\setuptools\build_meta.py", line 141, in run_setup
      exec(compile(code, __file__, 'exec'), locals())
    File "setup.py", line 99, in <module>
      + read("AUTHORS.rst")
  AttributeError: 'NoneType' object has no attribute 'group'
```
hynek: I wasn’t aware of that problem until recently. Could you help my understand how it happens in #561?
hynek: I believe pip install -dev on windows should work now?
kurtbrose: yes!  worked like a charm after update, thanks!
kurtbrose: just an FYI, pre-commit still fails with an SSL failure trying to fetch black from github

Errors:
    fatal: unable to access 'https://github.com/ambv/black/': error:1407742E:SSL routines:SSL23_GET_SERVER_HELLO:tlsv1 alert protocol version

I've checked the code is flake8 clean, hopefully I'm not missing anything else format wise
hynek: > just an FYI, pre-commit still fails with an SSL failure trying to fetch black from github
> 
> Errors:
> fatal: unable to access 'https://github.com/ambv/black/': error:1407742E:SSL routines:SSL23_GET_SERVER_HELLO:tlsv1 alert protocol version

Hm, that must be some local problem. Under what Python version is your pre-commit running? It must be something ancient, if the ssl is too old to connect to GitHub?! Anthony is AFAIK using Windows too, so that shouldn't be the issue. The tox env uses explicitly 3.7 tho?

(I've took this opportunity to fix black's URL tho)

> I've checked the code is flake8 clean, hopefully I'm not missing anything else format wise

You did: https://attrs.visualstudio.com/attrs/_build/results?buildId=231&view=logs&jobId=810e27dd-b084-5a0b-5c49-7fe688ece5ee&taskId=2dbb6c1a-2c14-506e-d517-3d9e1024c6b7&lineStart=39&lineEnd=40&colStart=1&colEnd=1 😇
hynek: Hey an release is imminent, do you think you can bang it into shape on short notice or do you want to push this to the next release?
kurtbrose: let me give it one more push this weekend and see if I can resolve everything -- sorry for being so flakey!
kurtbrose: alright I think this is ready to go now ... only thing is I'm not sure about hypothesis or the type testing area

not sure if those apply since this is more of a "leaf" than structural

Patch:
diff --git a/changelog.d/552.change.rst b/changelog.d/552.change.rst
new file mode 100644
index 0000000..0ff1a5e
--- /dev/null
+++ b/changelog.d/552.change.rst
@@ -0,0 +1,1 @@
+Added ``attr.validators.matches_re``
diff --git a/docs/api.rst b/docs/api.rst
index 1e102a8..fe3e4a4 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -400,6 +400,24 @@ Validators
         attr.exceptions.NotCallableError: 'x' must be callable (got 'not a callable' that is a <class 'str'>).
 
 
+.. autofunction:: attr.validators.matches_re
+
+    For example:
+
+    .. doctest::
+
+        >>> @attr.s
+        ... class User(object):
+        ...     email = attr.ib(validator=attr.validators.matches_re(
+        ...         "(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)"))
+        >>> User(email="user@example.com")
+        User(email='user@example.com')
+        >>> User(email="user@example.com@test.com")
+        Traceback (most recent call last):
+            ...
+        ValueError: ("'email' must match regex '(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\\\.[a-zA-Z0-9-.]+$)' ('user@example.com@test.com' doesn't)", Attribute(name='email', default=NOTHING, validator=<matches_re validator for pattern re.compile('(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$)')>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), re.compile('(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$)'), 'user@example.com@test.com')
+
+
 .. autofunction:: attr.validators.deep_iterable
 
     For example:
diff --git a/src/attr/validators.py b/src/attr/validators.py
index ed3f22a..a0cd25c 100644
--- a/src/attr/validators.py
+++ b/src/attr/validators.py
@@ -4,6 +4,8 @@ Commonly useful validators.
 
 from __future__ import absolute_import, division, print_function
 
+import re
+
 from ._make import _AndValidator, and_, attrib, attrs
 from .exceptions import NotCallableError
 
@@ -15,6 +17,7 @@ __all__ = [
     "in_",
     "instance_of",
     "is_callable",
+    "matches_re",
     "optional",
     "provides",
 ]
@@ -64,6 +67,76 @@ def instance_of(type):
     return _InstanceOfValidator(type)
 
 
+@attrs(repr=False, frozen=True)
+class _MatchesReValidator(object):
+    regex = attrib()
+    flags = attrib()
+    match_func = attrib()
+
+    def __call__(self, inst, attr, value):
+        """
+        We use a callable class to be able to change the ``__repr__``.
+        """
+        if not self.match_func(value):
+            raise ValueError(
+                "'{name}' must match regex {regex!r}"
+                " ({value!r} doesn't)".format(
+                    name=attr.name, regex=self.regex.pattern, value=value
+                ),
+                attr,
+                self.regex,
+                value,
+            )
+
+    def __repr__(self):
+        return "<matches_re validator for pattern {regex!r}>".format(
+            regex=self.regex
+        )
+
+
+def matches_re(regex, flags=0, func=None):
+    """
+    A validator that raises :exc:`ValueError` if the initializer is called
+    with a string that doesn't match the given regex, and :exc:`TypeError`
+    if the initializer is called with a non-string.
+
+    :param regex: a regex string to match against
+    :param flags: flags that will be passed to the underlying re function
+        (default 0)
+    :param func: which underlying re function to call (options are
+        `re.fullmatch()`, `re.search()`, `re.match()`, default `re.fullmatch`)
+
+    .. versionadded:: 19.1.0
+    """
+    fullmatch = getattr(re, "fullmatch", None)
+    valid_funcs = (fullmatch, None, re.search, re.match)
+    if func not in valid_funcs:
+        raise ValueError(
+            "'func' must be one of {}".format(
+                ", ".join([repr(e) for e in set(valid_funcs)])
+            )
+        )
+    # non-int flags gives an okay error message in re, so rely on that
+    if func is None:
+        if fullmatch:
+            func = fullmatch
+        else:
+            # python 2 fullmatch emulation
+            if isinstance(regex, (type(u""), type(b""))):
+                # pylint flags references to basestring or unicode builtins
+                # avoid swallowing TypeError if regex is not basestring
+                regex = r"(?:{})\Z".format(regex)
+            func = re.match
+    regex = re.compile(regex, flags)
+    if func is re.match:
+        match_func = regex.match
+    elif func is re.search:
+        match_func = regex.search
+    elif func is re.fullmatch:  # pragma: no branch
+        match_func = regex.fullmatch
+    return _MatchesReValidator(regex, flags, match_func)
+
+
 @attrs(repr=False, slots=True, hash=True)
 class _ProvidesValidator(object):
     interface = attrib()
diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi
index c0ef525..cc90819 100644
--- a/src/attr/validators.pyi
+++ b/src/attr/validators.pyi
@@ -9,6 +9,7 @@ from typing import (
     Tuple,
     Iterable,
     Mapping,
+    Callable,
 )
 from . import _ValidatorType
 
@@ -27,6 +28,9 @@ def optional(
 ) -> _ValidatorType[Optional[_T]]: ...
 def in_(options: Container[_T]) -> _ValidatorType[_T]: ...
 def and_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ...
+def matches_re(
+    regex: str, flags: int, func: Union[None, Callable[[str, str, int], ...]]
+): ...
 def deep_iterable(
     member_validator: _ValidatorType[_T],
     iterable_validator: Optional[_ValidatorType[_I]] = ...,


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index eb87867..c5fe741 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -4,6 +4,8 @@ Tests for `attr.validators`.
 
 from __future__ import absolute_import, division, print_function
 
+import re
+
 import pytest
 import zope.interface
 
@@ -19,6 +21,7 @@ from attr.validators import (
     in_,
     instance_of,
     is_callable,
+    matches_re,
     optional,
     provides,
 )
@@ -78,6 +81,66 @@ class TestInstanceOf(object):
         ) == repr(v)
 
 
+class TestMatchesRe(object):
+    """
+    Tests for `matches_re`.
+    """
+
+    def test_in_all(self):
+        """
+        validator is in ``__all__``.
+        """
+        assert matches_re.__name__ in validator_module.__all__
+
+    def test_match(self):
+        """
+        default re.match behavior
+        """
+
+        @attr.s
+        class ReTester(object):
+            str_match = attr.ib(validator=matches_re("a"))
+
+        ReTester("a")  # shouldn't raise exceptions
+        with pytest.raises(TypeError):
+            ReTester(1)
+        with pytest.raises(ValueError):
+            ReTester("1")
+        with pytest.raises(ValueError):
+            ReTester("a1")
+
+    def test_extra_args(self):
+        """
+        flags and non-default match function behavior
+        """
+
+        @attr.s
+        class MatchTester(object):
+            val = attr.ib(validator=matches_re("a", re.IGNORECASE, re.match))
+
+        MatchTester("A1")  # test flags and using re.match
+
+        @attr.s
+        class SearchTester(object):
+            val = attr.ib(validator=matches_re("a", 0, re.search))
+
+        SearchTester("bab")  # re.search will match
+
+    def test_bad_args_and_repr(self):
+        """
+        miscellaneous behaviors: repr and
+        """
+        with pytest.raises(TypeError):
+            matches_re(0)
+        with pytest.raises(TypeError):
+            matches_re("a", "a")
+        with pytest.raises(ValueError):
+            matches_re("a", 0, lambda: None)
+        for m in (None, getattr(re, "fullmatch", None), re.match, re.search):
+            matches_re("a", 0, m)
+        repr(matches_re("a"))
+
+
 def always_pass(_, __, ___):
     """
     Toy validator that always passses.