Skip to content

Commit

Permalink
Extend constraints of string #150 (#151)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
jonschumacher and jonasschumacher authored Oct 28, 2021
1 parent 770baec commit e5a62d0
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 9 deletions.
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `!`.
Expand Down
123 changes: 117 additions & 6 deletions yamale/validators/constraints.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions yamale/validators/tests/test_constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,89 @@ 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')
assert not v.is_valid('abc')
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')
Expand Down
8 changes: 7 additions & 1 deletion yamale/validators/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit e5a62d0

Please sign in to comment.