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?

Pull request description:
Add support for re.Pattern to validators.matches_re()
Fixes #875.

This adds support for pre-compiled regular expressions to
attr.validators.matches_re(). This cannot be combined with flags since
the pre-compiled pattern already has those.

Detailed changes:

- attr.validators.matches_re() now accepts re.Pattern in addition to
  strings; update type annotations accordingly.
- Convert percent-formatting into str.format() for an error message
- Simplify (private) _MatchesReValidator helper class a bit: use the
  actual compiled pattern, and drop the unused .flags attribute.
- Simplify control flow a bit; add pointer about fullmatch emulation.
- Add tests
- Tweak existing test to ensure that .fullmatch() actually works
  correctly by matching a pattern that also matches (but not
  ‘full-matches’) a shorter substring.

# Pull Request Check List

- [x] Added **tests** for changed code.
- [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] 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).


Patch:
diff --git a/changelog.d/877.change.rst b/changelog.d/877.change.rst
new file mode 100644
index 0000000..b902090
--- /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 1eb2572..3896d83 100644
--- a/src/attr/validators.py
+++ b/src/attr/validators.py
@@ -14,6 +14,12 @@ from ._make import _AndValidator, and_, attrib, attrs
 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 @@ class _MatchesReValidator(object):
         """
         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 a439332..5e00b85 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 e1b83ed..fb4382a 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -176,9 +176,9 @@ class TestMatchesRe(object):
 
         @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 TestMatchesRe(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 af12466..75b41b8 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)
     )