From 8fdd99102ac492db01d51bb0e2f89517f6e08586 Mon Sep 17 00:00:00 2001 From: artempronevskiy Date: Fri, 22 Nov 2024 12:52:55 +0100 Subject: [PATCH] Add "search", "match" and "fullmatch" modes to Regexp validator --- docs/fields.rst | 2 +- src/wtforms/validators.py | 34 ++++- tests/validators/test_regexp.py | 221 +++++++++++++++++++++++++++++--- 3 files changed, 237 insertions(+), 20 deletions(-) diff --git a/docs/fields.rst b/docs/fields.rst index 81faee0d1..a8bff5a01 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -233,7 +233,7 @@ refer to a single input from the form. Example usage:: class UploadForm(Form): - image = FileField('Image File', [validators.regexp('^[^/\\]\.jpg$')]) + image = FileField('Image File', [validators.regexp('^[^/\\]\.jpg$', mode='fullmatch')]) description = TextAreaField('Image Description') def validate_image(form, field): diff --git a/src/wtforms/validators.py b/src/wtforms/validators.py index 3536963bf..3963cf3e1 100644 --- a/src/wtforms/validators.py +++ b/src/wtforms/validators.py @@ -32,6 +32,7 @@ "mac_address", "UUID", "ValidationError", + "ValidatorSetupError", "StopValidation", "readonly", "ReadOnly", @@ -40,6 +41,15 @@ ) +class ValidatorSetupError(ValueError): + """ + Raised when a validator is configured improperly. + """ + + def __init__(self, message="", *args, **kwargs): + ValueError.__init__(self, message, *args, **kwargs) + + class ValidationError(ValueError): """ Raised when a validator fails to validate its input. @@ -340,16 +350,36 @@ class Regexp: `regex` is not a string. :param message: Error message to raise in case of a validation error. + :param mode: + The matching mode to use. Must be one of "search", "match", or + "fullmatch". Defaults to "match". """ - def __init__(self, regex, flags=0, message=None): + _supported_modes = ("search", "match", "fullmatch") + + def __init__(self, regex, flags=0, message=None, mode="match"): + self.mode = self._validate_mode(mode) if isinstance(regex, str): regex = re.compile(regex, flags) self.regex = regex self.message = message + def _validate_mode(self, mode): + if mode not in self._supported_modes: + raise ValidatorSetupError( + "Invalid mode value '{}'. Supported values: {}".format( + mode, ", ".join(self._supported_modes) + ) + ) + return mode + + def _get_validator(self): + return getattr(self.regex, self.mode) + def __call__(self, form, field, message=None): - match = self.regex.match(field.data or "") + validator = self._get_validator() + + match = validator(field.data or "") if match: return match diff --git a/tests/validators/test_regexp.py b/tests/validators/test_regexp.py index 7461c678f..9e69aa847 100644 --- a/tests/validators/test_regexp.py +++ b/tests/validators/test_regexp.py @@ -4,6 +4,7 @@ from wtforms.validators import regexp from wtforms.validators import ValidationError +from wtforms.validators import ValidatorSetupError def grab_error_message(callable, form, field): @@ -14,43 +15,187 @@ def grab_error_message(callable, form, field): @pytest.mark.parametrize( - "re_pattern, re_flags, test_v, expected_v", + "re_pattern, re_flags, re_mode, test_v, expected_v", [ - ("^a", None, "abcd", "a"), - ("^a", re.I, "ABcd", "A"), - (re.compile("^a"), None, "abcd", "a"), - (re.compile("^a", re.I), None, "ABcd", "A"), + # match mode + ("^a", None, "match", "abcd", "a"), + ("^a", re.I, "match", "ABcd", "A"), + ("^ab", None, "match", "abcd", "ab"), + ("^ab", re.I, "match", "ABcd", "AB"), + ("^abcd", None, "match", "abcd", "abcd"), + ("^abcd", re.I, "match", "ABcd", "ABcd"), + (r"^\w+", None, "match", "abcd", "abcd"), + (r"^\w+", re.I, "match", "ABcd", "ABcd"), + (re.compile("^a"), None, "match", "abcd", "a"), + (re.compile("^a", re.I), None, "match", "ABcd", "A"), + (re.compile("^ab"), None, "match", "abcd", "ab"), + (re.compile("^ab", re.I), None, "match", "ABcd", "AB"), + (re.compile("^abcd"), None, "match", "abcd", "abcd"), + (re.compile("^abcd", re.I), None, "match", "ABcd", "ABcd"), + (re.compile(r"^\w+"), None, "match", "abcd", "abcd"), + (re.compile(r"^\w+", re.I), None, "match", "ABcd", "ABcd"), + # fullmatch mode + ("^abcd", None, "fullmatch", "abcd", "abcd"), + ("^abcd", re.I, "fullmatch", "ABcd", "ABcd"), + ("^abcd$", None, "fullmatch", "abcd", "abcd"), + ("^abcd$", re.I, "fullmatch", "ABcd", "ABcd"), + (r"^\w+", None, "fullmatch", "abcd", "abcd"), + (r"^\w+", re.I, "fullmatch", "ABcd", "ABcd"), + (r"^\w+$", None, "fullmatch", "abcd", "abcd"), + (r"^\w+$", re.I, "fullmatch", "ABcd", "ABcd"), + (re.compile("^abcd"), None, "fullmatch", "abcd", "abcd"), + (re.compile("^abcd", re.I), None, "fullmatch", "ABcd", "ABcd"), + (re.compile("^abcd$"), None, "fullmatch", "abcd", "abcd"), + (re.compile("^abcd$", re.I), None, "fullmatch", "ABcd", "ABcd"), + (re.compile(r"^\w+"), None, "fullmatch", "abcd", "abcd"), + (re.compile(r"^\w+", re.I), None, "fullmatch", "ABcd", "ABcd"), + (re.compile(r"^\w+$"), None, "fullmatch", "abcd", "abcd"), + (re.compile(r"^\w+$", re.I), None, "fullmatch", "ABcd", "ABcd"), + # search mode + ("^a", None, "search", "abcd", "a"), + ("^a", re.I, "search", "ABcd", "A"), + ("bc", None, "search", "abcd", "bc"), + ("bc", re.I, "search", "ABcd", "Bc"), + ("cd$", None, "search", "abcd", "cd"), + ("cd$", re.I, "search", "ABcd", "cd"), + (r"\w", None, "search", "abcd", "a"), + (r"\w", re.I, "search", "ABcd", "A"), + (r"\w$", None, "search", "abcd", "d"), + (r"\w$", re.I, "search", "ABcd", "d"), + (r"\w+", None, "search", "abcd", "abcd"), + (r"\w+", re.I, "search", "ABcd", "ABcd"), + (r"\w+$", None, "search", "abcd", "abcd"), + (r"\w+$", re.I, "search", "ABcd", "ABcd"), + (re.compile("^a"), None, "search", "abcd", "a"), + (re.compile("^a", re.I), None, "search", "ABcd", "A"), + (re.compile(r"d$"), None, "search", "abcd", "d"), + (re.compile(r"d$", re.I), None, "search", "ABcd", "d"), + (re.compile("bc"), None, "search", "abcd", "bc"), + (re.compile("bc", re.I), None, "search", "ABcd", "Bc"), + (re.compile(r"\w"), None, "search", "abcd", "a"), + (re.compile(r"\w", re.I), None, "search", "ABcd", "A"), + (re.compile(r"\w+"), None, "search", "abcd", "abcd"), + (re.compile(r"\w+", re.I), None, "search", "ABcd", "ABcd"), ], ) def test_regex_passes( - re_pattern, re_flags, test_v, expected_v, dummy_form, dummy_field + re_pattern, re_flags, re_mode, test_v, expected_v, dummy_form, dummy_field ): """ Regex should pass if there is a match. - Should work for complie regex too + Should work for compile regex too """ - validator = regexp(re_pattern, re_flags) if re_flags else regexp(re_pattern) + kwargs = { + "regex": re_pattern, + "flags": re_flags if re_flags else 0, + "message": None, + "mode": re_mode, + } + validator = regexp(**kwargs) dummy_field.data = test_v assert validator(dummy_form, dummy_field).group(0) == expected_v @pytest.mark.parametrize( - "re_pattern, re_flags, test_v", + "re_pattern, re_flags, re_mode, test_v", [ - ("^a", None, "ABC"), - ("^a", re.I, "foo"), - ("^a", None, None), - (re.compile("^a"), None, "foo"), - (re.compile("^a", re.I), None, None), + # math mode + ("^a", None, "match", "ABC"), + ("^a", re.I, "match", "foo"), + ("^a", None, "match", None), + ("^ab", None, "match", "ABC"), + ("^ab", re.I, "match", "foo"), + ("^ab", None, "match", None), + ("^ab$", None, "match", "ABC"), + ("^ab$", re.I, "match", "foo"), + ("^ab$", None, "match", None), + (re.compile("^a"), None, "match", "ABC"), + (re.compile("^a", re.I), None, "match", "foo"), + (re.compile("^a"), None, "match", None), + (re.compile("^ab"), None, "match", "ABC"), + (re.compile("^ab", re.I), None, "match", "foo"), + (re.compile("^ab"), None, "match", None), + (re.compile("^ab$"), None, "match", "ABC"), + (re.compile("^ab$", re.I), None, "match", "foo"), + (re.compile("^ab$"), None, "match", None), + # fullmatch mode + ("^abcd", None, "fullmatch", "abc"), + ("^abcd", re.I, "fullmatch", "abc"), + ("^abcd", None, "fullmatch", "foo"), + ("^abcd", re.I, "fullmatch", "foo"), + ("^abcd", None, "fullmatch", None), + ("^abcd", re.I, "fullmatch", None), + ("abcd$", None, "fullmatch", "abc"), + ("abcd$", re.I, "fullmatch", "abc"), + ("abcd$", None, "fullmatch", "foo"), + ("abcd$", re.I, "fullmatch", "foo"), + ("abcd$", None, "fullmatch", None), + ("abcd$", re.I, "fullmatch", None), + ("^abcd$", None, "fullmatch", "abc"), + ("^abcd$", re.I, "fullmatch", "abc"), + ("^abcd$", None, "fullmatch", "foo"), + ("^abcd$", re.I, "fullmatch", "foo"), + ("^abcd$", None, "fullmatch", None), + ("^abcd$", re.I, "fullmatch", None), + (re.compile("^abcd"), None, "fullmatch", "abc"), + (re.compile("^abcd", re.I), None, "fullmatch", "abc"), + (re.compile("^abcd"), None, "fullmatch", "foo"), + (re.compile("^abcd", re.I), None, "fullmatch", "foo"), + (re.compile("^abcd"), None, "fullmatch", None), + (re.compile("^abcd", re.I), None, "fullmatch", None), + (re.compile("abcd$"), None, "fullmatch", "abc"), + (re.compile("abcd$", re.I), None, "fullmatch", "abc"), + (re.compile("abcd$"), None, "fullmatch", "foo"), + (re.compile("abcd$", re.I), None, "fullmatch", "foo"), + (re.compile("abcd$"), None, "fullmatch", None), + (re.compile("abcd$", re.I), None, "fullmatch", None), + (re.compile("^abcd$"), None, "fullmatch", "abc"), + (re.compile("^abcd$", re.I), None, "fullmatch", "abc"), + (re.compile("^abcd$"), None, "fullmatch", "foo"), + (re.compile("^abcd$", re.I), None, "fullmatch", "foo"), + (re.compile("^abcd$"), None, "fullmatch", None), + (re.compile("^abcd$", re.I), None, "fullmatch", None), + # search mode + ("^a", None, "search", "foo"), + ("^a", re.I, "search", "foo"), + ("^a", None, "search", None), + ("^a", re.I, "search", None), + ("bc", None, "search", "foo"), + ("bc", re.I, "search", "foo"), + ("bc", None, "search", None), + ("bc", re.I, "search", None), + ("cd$", None, "search", "foo"), + ("cd$", re.I, "search", "foo"), + ("cd$", None, "search", None), + ("cd$", re.I, "search", None), + (re.compile("^a"), None, "search", "foo"), + (re.compile("^a", re.I), None, "search", "foo"), + (re.compile("^a"), None, "search", None), + (re.compile("^a", re.I), None, "search", None), + (re.compile("bc"), None, "search", "foo"), + (re.compile("bc", re.I), None, "search", "foo"), + (re.compile("bc"), None, "search", None), + (re.compile("bc", re.I), None, "search", None), + (re.compile(r"cd$"), None, "search", "foo"), + (re.compile(r"cd$", re.I), None, "search", "foo"), + (re.compile(r"cd$"), None, "search", None), + (re.compile(r"cd$", re.I), None, "search", None), ], ) -def test_regex_raises(re_pattern, re_flags, test_v, dummy_form, dummy_field): +def test_regex_raises(re_pattern, re_flags, re_mode, test_v, dummy_form, dummy_field): """ Regex should raise ValidationError if there is no match - Should work for complie regex too + Should work for compile regex too """ - validator = regexp(re_pattern, re_flags) if re_flags else regexp(re_pattern) + kwargs = { + "regex": re_pattern, + "flags": re_flags if re_flags else 0, + "message": None, + "mode": re_mode, + } + validator = regexp(**kwargs) dummy_field.data = test_v + with pytest.raises(ValidationError): validator(dummy_form, dummy_field) @@ -62,3 +207,45 @@ def test_regexp_message(dummy_form, dummy_field): validator = regexp("^a", message="foo") dummy_field.data = "f" assert grab_error_message(validator, dummy_form, dummy_field) == "foo" + + +@pytest.mark.parametrize( + "re_mode", + [ + "MATCH", + "SEARCH", + "FULLMATCH", + "Match", + "Search", + "Fullmatch", + "", + "match ", + " match", + "search ", + " search", + "fullmatch ", + " fullmatch", + None, + 1, + 1.0, + True, + False, + [], + {}, + (), + ], +) +def test_regex_invalid_mode(dummy_form, dummy_field, re_mode): + """ + Regexp validator should raise ValidatorSetupError during an object instantiation, + if mode is invalid (unsupported). + """ + with pytest.raises(ValidatorSetupError) as e: + regexp("^a", mode=re_mode) + + expected_msg_tmpl = ( + "Invalid mode value '{}'. " + "Supported values: search, match, fullmatch" + ) + + assert e.value.args[0] == expected_msg_tmpl.format(re_mode)