From 1759260aa0bb0ef62fdfcab8636af9d4cfa91c14 Mon Sep 17 00:00:00 2001 From: wouter bolsterlee Date: Tue, 30 Nov 2021 05:32:24 +0000 Subject: [PATCH] Add support for re.Pattern to validators.matches_re() (#877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- changelog.d/877.change.rst | 1 + src/attr/validators.py | 53 ++++++++++++++++++++++++-------------- src/attr/validators.pyi | 3 ++- tests/test_validators.py | 27 +++++++++++++++++-- tests/typing_example.py | 2 +- 5 files changed, 63 insertions(+), 23 deletions(-) create mode 100644 changelog.d/877.change.rst diff --git a/changelog.d/877.change.rst b/changelog.d/877.change.rst new file mode 100644 index 00000000..b9020902 --- /dev/null +++ b/changelog.d/877.change.rst @@ -0,0 +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 1eb25722..3896d834 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 "".format( - regex=self.regex + return "".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 a4393327..5e00b854 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]]] diff --git a/tests/test_validators.py b/tests/test_validators.py index e1b83ed4..fb4382a9 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 af124661..75b41b89 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) )