Skip to content

Commit

Permalink
Merge pull request #29 from maximkulkin/list-validations
Browse files Browse the repository at this point in the history
List validations
  • Loading branch information
maximkulkin authored Aug 23, 2016
2 parents 08f6ce8 + b679135 commit 7d0b9f3
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 9 deletions.
8 changes: 4 additions & 4 deletions lollipop/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ def __init__(self, error_messages=None, *args, **kwargs):
self._error_messages.update(getattr(cls, 'default_error_messages', {}))
self._error_messages.update(error_messages or {})

def _fail(self, key, **kwargs):
if key not in self._error_messages:
def _fail(self, error_key, **kwargs):
if error_key not in self._error_messages:
msg = MISSING_ERROR_MESSAGE.format(
class_name=self.__class__.__name__,
key=key
error_key=error_key
)
raise ValueError(msg)

msg = self._error_messages[key]
msg = self._error_messages[error_key]
if isinstance(msg, str):
msg = msg.format(**kwargs)

Expand Down
93 changes: 89 additions & 4 deletions lollipop/validators.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from lollipop.errors import ValidationError, ErrorMessagesMixin
from lollipop.errors import ValidationError, ValidationErrorBuilder, \
ErrorMessagesMixin
from lollipop.compat import string_types
from lollipop.utils import call_with_context
from lollipop.utils import call_with_context, is_list
import re


Expand All @@ -12,6 +13,8 @@
'NoneOf',
'AnyOf',
'Regexp',
'Unique',
'Each',
]


Expand Down Expand Up @@ -81,10 +84,13 @@ class Range(Validator):
'range': 'Value should be at least {min} and at most {max}',
}

def __init__(self, min=None, max=None, **kwargs):
def __init__(self, min=None, max=None, error=None, **kwargs):
super(Range, self).__init__(**kwargs)
self.min = min
self.max = max
if error is not None:
for key in ['min', 'max', 'range']:
self._error_messages[key] = error

def _fail(self, key, **kwargs):
super(Range, self)._fail(key, min=self.min, max=self.max, **kwargs)
Expand Down Expand Up @@ -127,11 +133,14 @@ class Length(Validator):
'range': 'Length should be at least {min} and at most {max}',
}

def __init__(self, exact=None, min=None, max=None, **kwargs):
def __init__(self, exact=None, min=None, max=None, error=None, **kwargs):
super(Length, self).__init__(**kwargs)
self.exact = exact
self.min = min
self.max = max
if error is not None:
for key in ['exact', 'min', 'max', 'range']:
self._error_messages[key] = error

def _fail(self, key, **kwargs):
super(Length, self)._fail(key, exact=self.exact, min=self.min, max=self.max,
Expand Down Expand Up @@ -251,3 +260,79 @@ def __repr__(self):
klass=self.__class__.__name__,
regexp=self.regexp.pattern,
)


class Unique(Validator):
"""Validator that succeeds if items in collection are unqiue.
By default items themselves should be unique, but you can specify a custom
function to get uniqueness key from items.
:param callable key: Function to get uniqueness key from items.
:param str error: Erorr message in case item appear more than once.
Can be interpolated with ``data`` (the item that is not unique)
and ``key`` (uniquness key that is not unique).
"""

default_error_messages = {
'invalid': 'Value should be collection',
'unique': 'Values are not unique',
}

def __init__(self, key=lambda x: x, error=None, **kwargs):
super(Unique, self).__init__(**kwargs)
self.key = key
if error is not None:
self._error_messages['unique'] = error

def __call__(self, value):
if not is_list(value):
self._fail('invalid')

seen = set()
for item in value:
key = self.key(item)
if key in seen:
self._fail('unique', data=item, key=key)
seen.add(key)

def __repr__(self):
return '<{klass}>'.format(klass=self.__class__.__name__)


class Each(Validator):
"""Validator that takes a list of validators and applies all of them to
each item in collection.
:param validators: Validator or list of validators to run against each element
of collection.
"""
default_error_messages = {
'invalid': 'Value should be collection',
}

def __init__(self, validators, **kwargs):
super(Validator, self).__init__(**kwargs)
if not is_list(validators):
validators = [validators]
self.validators = validators

def __call__(self, value):
if not is_list(value):
self._fail('invalid', data=value)

error_builder = ValidationErrorBuilder()

for idx, item in enumerate(value):
for validator in self.validators:
try:
validator(item)
except ValidationError as ve:
error_builder.add_errors({idx: ve.messages})

error_builder.raise_errors()

def __repr__(self):
return "<{klass} {validators!r}>".format(
klass=self.__class__.__name__,
validators=self.validators,
)
116 changes: 115 additions & 1 deletion tests/test_validators.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pytest import raises
from contextlib import contextmanager
from lollipop.validators import Predicate, Range, Length, NoneOf, AnyOf, Regexp
from lollipop.validators import Predicate, Range, Length, NoneOf, AnyOf, Regexp, \
Unique, Each
from lollipop.errors import ValidationError
import re

Expand Down Expand Up @@ -120,6 +121,21 @@ def test_customzing_range_error_message(self):
Range(min=1, max=5, error_messages={'range': message})(0)
assert exc_info.value.messages == message.format(data=0, min=1, max=5)

def test_customizing_all_error_messages_at_once(self):
message = 'Value is invalid'

with raises(ValidationError) as exc_info:
Range(min=1, error=message)(0)
assert exc_info.value.messages == message

with raises(ValidationError) as exc_info:
Range(max=1, error=message)(2) == message
assert exc_info.value.messages == message

with raises(ValidationError) as exc_info:
Range(min=1, max=5, error=message)(0) == message
assert exc_info.value.messages == message


class TestLength:
def test_matching_exact_value(self):
Expand Down Expand Up @@ -206,6 +222,25 @@ def test_customzing_range_error_message(self):
assert exc_info.value.messages == \
message.format(data=[], length=0, min=1, max=5)

def test_customizing_all_error_messages_at_once(self):
message = 'Value is invalid'

with raises(ValidationError) as exc_info:
Length(exact=1, error=message)([])
assert exc_info.value.messages == message

with raises(ValidationError) as exc_info:
Length(min=1, error=message)([])
assert exc_info.value.messages == message

with raises(ValidationError) as exc_info:
Length(max=1, error=message)([1, 2]) == message
assert exc_info.value.messages == message

with raises(ValidationError) as exc_info:
Length(min=1, max=5, error=message)([]) == message
assert exc_info.value.messages == message


class TestNoneOf:
def test_matching_values_other_than_given_values(self):
Expand Down Expand Up @@ -278,3 +313,82 @@ def test_customizing_error_message(self):
with raises(ValidationError) as exc_info:
Regexp('a+b', error=message)('bbc')
assert exc_info.value.messages == message.format(data='bbc', regexp='a+b')


class TestUnique:
def test_raising_ValidationError_if_value_is_not_collection(self):
with raises(ValidationError) as exc_info:
Unique()('foo')
assert exc_info.value.messages == Unique.default_error_messages['invalid']

def test_matching_empty_collection(self):
with not_raises(ValidationError):
Unique()([])

def test_matching_collection_of_unique_values(self):
with not_raises(ValidationError):
Unique()(['foo', 'bar', 'baz'])

def test_matching_collection_of_values_with_unique_custom_keys(self):
class Foo:
def __init__(self, foo):
self.foo = foo

with not_raises(ValidationError):
Unique(lambda x: x.foo)([Foo('foo'), Foo('bar'), Foo('baz')])

def test_raising_ValidationError_if_item_appears_more_than_once(self):
with raises(ValidationError) as exc_info:
Unique()(['foo', 'bar', 'foo'])
assert exc_info.value.messages == Unique.default_error_messages['unique']

def test_raising_ValidationError_if_custom_key_appears_more_than_once(self):
class Foo:
def __init__(self, foo):
self.foo = foo

with raises(ValidationError) as exc_info:
Unique(lambda x: x.foo)([Foo('foo'), Foo('bar'), Foo('foo')])
assert exc_info.value.messages == Unique.default_error_messages['unique']

def test_customizing_error_message(self):
class Foo:
def __init__(self, foo):
self.foo = foo

message = 'Invalid data {data} with key {key}'
x = Foo('foo')
y = Foo('foo')
with raises(ValidationError) as exc_info:
Unique(lambda x: x.foo, error=message)([x, y])
assert exc_info.value.messages == message.format(data=y, key='foo')


is_odd = Predicate(lambda x: x % 2 == 1, 'Value should be odd')
is_small = Predicate(lambda x: x <= 5, 'Value should be small')


class TestEach:
def test_raising_ValidationError_if_value_is_not_collection(self):
with raises(ValidationError) as exc_info:
Each(lambda x: x)('foo')
assert exc_info.value.messages == Each.default_error_messages['invalid']

def test_matching_empty_collections(self):
with not_raises(ValidationError):
Each(is_odd)([])

def test_matching_collections_each_elemenet_of_which_matches_given_validators(self):
with not_raises(ValidationError):
Each([is_odd, is_small])([1, 3, 5])

def test_raising_ValidationError_if_single_validator_fails(self):
with raises(ValidationError) as exc_info:
Each(is_odd)([1, 2, 3])
assert exc_info.value.messages == {1: 'Value should be odd'}

def test_raising_ValidationError_if_any_item_fails_any_validator(self):
with raises(ValidationError) as exc_info:
Each([is_odd, is_small])([1, 2, 5, 7])
assert exc_info.value.messages == {1: 'Value should be odd',
3: 'Value should be small'}

0 comments on commit 7d0b9f3

Please sign in to comment.