Add support for re.Pattern to validators.matches_re() (#877)

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.

Co-authored-by: Hynek Schlawack <hs@ox.cx>
This commit is contained in:
wouter bolsterlee 2021-11-30 05:32:24 +00:00 committed by GitHub
parent fe19f4b341
commit 1759260aa0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 63 additions and 23 deletions

View File

@ -0,0 +1 @@
``attr.validators.matches_re()`` now accepts pre-compiled regular expressions in addition to pattern strings.

View File

@ -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)

View File

@ -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]]]

View File

@ -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.

View File

@ -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)
)