Repository: python-attrs/attrs (Python)

Issue:
matches_re() should accept re.Pattern in addition to str
`attr.validators.matches_re()` does not accept precompiled regular expressions, and instead requires a `str` pattern (and optional flags).

this is not ideal when using existing compiled regular expressions, which already can have flags etc.

it would be a nice addition if the same helper accepts `re.Pattern` (py37+) or `typing.Pattern` for those living in the past.

open questions:

- would `flags=` be allowed if a `re.Pattern` is passed? compiled patterns cannot be ‘reinterpreted’ safely with different flags (e.g. `re.VERBOSE` changes many semantics). that means options are a) reject by raising an exception or b) ignore. my preference is raising.
- in general any reason not to do this?


Discussion:
(oh and i can take care of pull request etc)
sure, go for it and raise if the alternative is not safe



Patch:
diff --git a/changelog.d/877.change.rst b/changelog.d/877.change.rst
new file mode 100644
index 000000000..b90209025
--- /dev/null
+++ b/changelog.d/877.change.rst
@@ -0,0 +1,1 @@
+``attr.validators.matches_re()`` now accepts pre-compiled regular expressions in addition to pattern strings.
diff --git a/src/attr/validators.py b/src/attr/validators.py
index 1eb257225..3896d8346 100644
--- a/src/attr/validators.py
+++ b/src/attr/validators.py
@@ -14,6 +14,12 @@
 from .exceptions import NotCallableError
 
 
+try:
+    Pattern = re.Pattern
+except AttributeError:  # Python <3.7 lacks a Pattern type.
+    Pattern = type(re.compile(""))
+
+
 __all__ = [
     "and_",
     "deep_iterable",
@@ -129,8 +135,7 @@ def instance_of(type):
 
 @attrs(repr=False, frozen=True, slots=True)
 class _MatchesReValidator(object):
-    regex = attrib()
-    flags = attrib()
+    pattern = attrib()
     match_func = attrib()
 
     def __call__(self, inst, attr, value):
@@ -139,18 +144,18 @@ def __call__(self, inst, attr, value):
         """
         if not self.match_func(value):
             raise ValueError(
-                "'{name}' must match regex {regex!r}"
+                "'{name}' must match regex {pattern!r}"
                 " ({value!r} doesn't)".format(
-                    name=attr.name, regex=self.regex.pattern, value=value
+                    name=attr.name, pattern=self.pattern.pattern, value=value
                 ),
                 attr,
-                self.regex,
+                self.pattern,
                 value,
             )
 
     def __repr__(self):
-        return "<matches_re validator for pattern {regex!r}>".format(
-            regex=self.regex
+        return "<matches_re validator for pattern {pattern!r}>".format(
+            pattern=self.pattern
         )
 
 
@@ -159,7 +164,7 @@ def matches_re(regex, flags=0, func=None):
     A validator that raises `ValueError` if the initializer is called
     with a string that doesn't match *regex*.
 
-    :param str regex: a regex string to match against
+    :param regex: a regex string or precompiled pattern to match against
     :param int flags: flags that will be passed to the underlying re function
         (default 0)
     :param callable func: which underlying `re` function to call (options
@@ -169,34 +174,44 @@ def matches_re(regex, flags=0, func=None):
         but on a pre-`re.compile`\ ed pattern.
 
     .. versionadded:: 19.2.0
+    .. versionchanged:: 21.3.0 *regex* can be a pre-compiled pattern.
     """
     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 %s."
-            % (
+            "'func' must be one of {}.".format(
                 ", ".join(
                     sorted(
                         e and e.__name__ or "None" for e in set(valid_funcs)
                     )
-                ),
+                )
             )
         )
 
-    pattern = re.compile(regex, flags)
+    if isinstance(regex, Pattern):
+        if flags:
+            raise TypeError(
+                "'flags' can only be used with a string pattern; "
+                "pass flags to re.compile() instead"
+            )
+        pattern = regex
+    else:
+        pattern = re.compile(regex, flags)
+
     if func is re.match:
         match_func = pattern.match
     elif func is re.search:
         match_func = pattern.search
-    else:
-        if fullmatch:
-            match_func = pattern.fullmatch
-        else:
-            pattern = re.compile(r"(?:{})\Z".format(regex), flags)
-            match_func = pattern.match
+    elif fullmatch:
+        match_func = pattern.fullmatch
+    else:  # Python 2 fullmatch emulation (https://bugs.python.org/issue16203)
+        pattern = re.compile(
+            r"(?:{})\Z".format(pattern.pattern), pattern.flags
+        )
+        match_func = pattern.match
 
-    return _MatchesReValidator(pattern, flags, match_func)
+    return _MatchesReValidator(pattern, match_func)
 
 
 @attrs(repr=False, slots=True, hash=True)
diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi
index a4393327c..5e00b8543 100644
--- a/src/attr/validators.pyi
+++ b/src/attr/validators.pyi
@@ -9,6 +9,7 @@ from typing import (
     Mapping,
     Match,
     Optional,
+    Pattern,
     Tuple,
     Type,
     TypeVar,
@@ -54,7 +55,7 @@ def optional(
 def in_(options: Container[_T]) -> _ValidatorType[_T]: ...
 def and_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ...
 def matches_re(
-    regex: AnyStr,
+    regex: Union[Pattern[AnyStr], AnyStr],
     flags: int = ...,
     func: Optional[
         Callable[[AnyStr, AnyStr, int], Optional[Match[AnyStr]]]


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index e1b83ed42..fb4382a9f 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -176,9 +176,9 @@ def test_match(self):
 
         @attr.s
         class ReTester(object):
-            str_match = attr.ib(validator=matches_re("a"))
+            str_match = attr.ib(validator=matches_re("a|ab"))
 
-        ReTester("a")  # shouldn't raise exceptions
+        ReTester("ab")  # shouldn't raise exceptions
         with pytest.raises(TypeError):
             ReTester(1)
         with pytest.raises(ValueError):
@@ -197,6 +197,29 @@ class MatchTester(object):
 
         MatchTester("A1")  # test flags and using re.match
 
+    def test_precompiled_pattern(self):
+        """
+        Pre-compiled patterns are accepted.
+        """
+        pattern = re.compile("a")
+
+        @attr.s
+        class RePatternTester(object):
+            val = attr.ib(validator=matches_re(pattern))
+
+        RePatternTester("a")
+
+    def test_precompiled_pattern_no_flags(self):
+        """
+        A pre-compiled pattern cannot be combined with a 'flags' argument.
+        """
+        pattern = re.compile("")
+
+        with pytest.raises(
+            TypeError, match="can only be used with a string pattern"
+        ):
+            matches_re(pattern, flags=re.IGNORECASE)
+
     def test_different_func(self):
         """
         Changing the match functions works.
diff --git a/tests/typing_example.py b/tests/typing_example.py
index af124661a..75b41b89d 100644
--- a/tests/typing_example.py
+++ b/tests/typing_example.py
@@ -167,7 +167,7 @@ class Validated:
             attr.validators.instance_of(C), attr.validators.instance_of(D)
         ),
     )
-    e: str = attr.ib(validator=attr.validators.matches_re(r"foo"))
+    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)
     )