From e5a62d03a39fdd6f64d80c22caad608ac3730c92 Mon Sep 17 00:00:00 2001 From: Jonas Schumacher Date: Thu, 28 Oct 2021 19:30:44 +0200 Subject: [PATCH] Extend constraints of string #150 (#151) * Add string constraints 'equals', 'starts_with', 'ends_with', 'matches' * Change CharacterExclude to allow for case insensitive matching * Updated README.md with ne string constraints * Fix type in README.md * Add documentation of ignore_case, multiline and dotall Co-authored-by: jonasschumacher --- README.md | 17 ++- yamale/validators/constraints.py | 123 ++++++++++++++++++++- yamale/validators/tests/test_constraint.py | 76 +++++++++++++ yamale/validators/validators.py | 8 +- 4 files changed, 215 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 16e6cec..2203155 100644 --- a/README.md +++ b/README.md @@ -264,12 +264,25 @@ Some validators take keywords and some take arguments, some take both. For insta validator takes one or more constants as arguments and the `required` keyword: `enum('a string', 1, False, required=False)` -### String - `str(min=int, max=int, exclude=string)` +### String - `str(min=int, max=int, equals=string, starts_with=string, ends_with=string, matches=regex, exclude=string, ignore_case=False, multiline=False, dotall=False)` Validates strings. - keywords - `min`: len(string) >= min - `max`: len(string) <= max - - `exclude`: Rejects strings that contains any character in the excluded value. + - `equals`: string == value (add `ignore_case=True` for case-insensitive checking) + - `starts_with`: Accepts only strings starting with given value (add `ignore_case=True` for + case-insensitive checking) + - `matches`: Validates the string against a given regex. Similar to the `regex()` validator, + you can use `ignore_case`, `multiline` and `dotall`) + - `ends_with`: Accepts only strings ending with given value (add `ignore_case=True` for case-insensitive checking) + - `exclude`: Rejects strings that contains any character in the excluded value + - `ignore_case`: Validates strings in a case-insensitive manner. + - `multiline`: `^` and `$` in a pattern match at the beginning and end of each line in a string + in addition to matching at the beginning and end of the entire string. (A pattern matches + at [the beginning of a string](https://docs.python.org/3/library/re.html#re.match) even in + multiline mode; see below for a workaround.); only allowed in conjunction with a `matches` keyword. + - `dotall`: `.` in a pattern matches newline characters in a validated string in addition to + matching every character that *isn't* a newline.; only allowed in conjunction with a `matches` keyword. Examples: - `str(max=10, exclude='?!')`: Allows only strings less than 11 characters that don't contain `?` or `!`. diff --git a/yamale/validators/constraints.py b/yamale/validators/constraints.py index 00cca33..3065a49 100644 --- a/yamale/validators/constraints.py +++ b/yamale/validators/constraints.py @@ -1,9 +1,11 @@ from __future__ import absolute_import +import re import datetime import ipaddress from yamale.util import to_unicode from .base import Validator +from .. import util class Constraint(object): @@ -124,16 +126,125 @@ def _fail(self, value): return [self.fail % (e) for e in error_list] +class StringEquals(Constraint): + keywords = {'equals': str, 'ignore_case': bool} + fail = '%s does not equal %s' + + def _is_valid(self, value): + # Check if the function has only been called due to ignore_case + if self.equals is not None: + if self.ignore_case is not None: + if not self.ignore_case: + return value == self.equals + else: + return value.casefold() == self.equals.casefold() + else: + return value == self.equals + else: + return True + + def _fail(self, value): + return self.fail % (value, self.equals) + + +class StringStartsWith(Constraint): + keywords = {'starts_with': str, 'ignore_case': bool} + fail = '%s does not start with %s' + + def _is_valid(self, value): + # Check if the function has only been called due to ignore_case + if self.starts_with is not None: + if self.ignore_case is not None: + if not self.ignore_case: + return value.startswith(self.starts_with) + else: + length = len(self.starts_with) + if length <= len(value): + return value[:length].casefold() == self.starts_with.casefold() + else: + return False + else: + return value.startswith(self.starts_with) + else: + return True + + def _fail(self, value): + return self.fail % (value, self.starts_with) + + +class StringEndsWith(Constraint): + keywords = {'ends_with': str, 'ignore_case': bool} + fail = '%s does not end with %s' + + def _is_valid(self, value): + # Check if the function has only been called due to ignore_case + if self.ends_with is not None: + if self.ignore_case is not None: + if not self.ignore_case: + return value.endswith(self.ends_with) + else: + length = len(self.ends_with) + if length <= len(value): + return value[-length:].casefold() == self.ends_with.casefold() + else: + return False + else: + return value.endswith(self.ends_with) + else: + return True + + def _fail(self, value): + return self.fail % (value, self.ends_with) + + +class StringMatches(Constraint): + keywords = {'matches': str} + fail = '%s is not a regex match.' + + _regex_flags = {'ignore_case': re.I, 'multiline': re.M, 'dotall': re.S} + + def __init__(self, value_type, kwargs): + self._flags = 0 + for k, v in util.get_iter(self._regex_flags): + self._flags |= v if kwargs.pop(k, False) else 0 + + super(StringMatches, self).__init__(value_type, kwargs) + + def _is_valid(self, value): + if self.matches is not None: + regex = re.compile(self.matches, self._flags) + return regex.match(value) + else: + return True + + def _fail(self, value): + return self.fail % (value) + + class CharacterExclude(Constraint): - keywords = {'exclude': str} + keywords = {'exclude': str, 'ignore_case': bool} fail = '\'%s\' contains excluded character \'%s\'' def _is_valid(self, value): - for char in self.exclude: - if char in value: - self._failed_char = char - return False - return True + # Check if the function has only been called due to ignore_case + if self.exclude is not None: + for char in self.exclude: + if self.ignore_case is not None: + if not self.ignore_case: + if char in value: + self._failed_char = char + return False + else: + if char.casefold() in value.casefold(): + self._failed_char = char + return False + else: + if char in value: + self._failed_char = char + return False + return True + else: + return True def _fail(self, value): return self.fail % (value, self._failed_char) diff --git a/yamale/validators/tests/test_constraint.py b/yamale/validators/tests/test_constraint.py index ad1a008..f5984de 100644 --- a/yamale/validators/tests/test_constraint.py +++ b/yamale/validators/tests/test_constraint.py @@ -58,6 +58,73 @@ def test_day_max(): assert not v.is_valid(datetime.date(2010, 2, 2)) +def test_str_equals(): + v = val.String(equals='abcd') + assert v.is_valid('abcd') + assert not v.is_valid('abcde') + assert not v.is_valid('c') + + +def test_str_equals_ignore_case(): + v = val.String(equals='abcd', ignore_case=True) + assert v.is_valid('abCd') + assert not v.is_valid('abcde') + assert not v.is_valid('C') + + +def test_str_starts_with(): + v = val.String(starts_with='abc') + assert v.is_valid('abcd') + assert not v.is_valid('bcd') + assert not v.is_valid('c') + + +def test_str_starts_with_ignore_case(): + v = val.String(starts_with='abC', ignore_case=True) + assert v.is_valid('abCde') + assert v.is_valid('abcde') + assert not v.is_valid('bcd') + assert not v.is_valid('C') + + +def test_str_ends_with(): + v = val.String(ends_with='abcd') + assert v.is_valid('abcd') + assert not v.is_valid('abcde') + assert not v.is_valid('c') + + +def test_str_ends_with_ignore_case(): + v = val.String(ends_with='abC', ignore_case=True) + assert v.is_valid('xyzabC') + assert v.is_valid('xyzabc') + assert not v.is_valid('cde') + assert not v.is_valid('C') + + +def test_str_matches(): + v = val.String(matches=r'^(abc)\1?de$') + assert v.is_valid('abcabcde') + assert not v.is_valid('abcabcabcde') + assert not v.is_valid('\12') + + v = val.String(matches=r'[a-z0-9]{3,}s\s$', ignore_case=True) + assert v.is_valid('b33S\v') + assert v.is_valid('B33s\t') + assert not v.is_valid(' b33s ') + assert not v.is_valid('b33s ') + + v = val.String(matches=r'A.+\d$', ignore_case=False, multiline=True) + assert v.is_valid('A_-3\n\n') + assert not v.is_valid('a!!!!!5\n\n') + + v = val.String(matches=r'.*^Ye.*s\.', ignore_case=True, multiline=True, dotall=True) + assert v.is_valid('YEeeEEEEeeeeS.') + assert v.is_valid('What?\nYes!\nBEES.\nOK.') + assert not v.is_valid('YES-TA-TOES?') + assert not v.is_valid('\n\nYaes.') + + def test_char_exclude(): v = val.String(exclude='abcd') assert v.is_valid('efg') @@ -65,6 +132,15 @@ def test_char_exclude(): assert not v.is_valid('c') +def test_char_exclude_igonre_case(): + v = val.String(exclude='abcd', ignore_case=True) + assert v.is_valid('efg') + assert v.is_valid('Efg') + assert not v.is_valid('abc') + assert not v.is_valid('Def') + assert not v.is_valid('c') + + def test_ip4(): v = val.Ip(version=4) assert v.is_valid('192.168.1.1') diff --git a/yamale/validators/validators.py b/yamale/validators/validators.py index 975252e..e5fc4e4 100644 --- a/yamale/validators/validators.py +++ b/yamale/validators/validators.py @@ -15,7 +15,13 @@ class String(Validator): """String validator""" tag = 'str' - constraints = [con.LengthMin, con.LengthMax, con.CharacterExclude] + constraints = [con.LengthMin, + con.LengthMax, + con.CharacterExclude, + con.StringEquals, + con.StringStartsWith, + con.StringEndsWith, + con.StringMatches] def _is_valid(self, value): return util.isstr(value)