Skip to content

Commit

Permalink
Merge pull request #45 from maximkulkin/dict-key-type
Browse files Browse the repository at this point in the history
Dict key type. Fixes #43
  • Loading branch information
maximkulkin authored Feb 7, 2017
2 parents 3b31214 + 2465b9d commit 823e493
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 12 deletions.
4 changes: 2 additions & 2 deletions lollipop/errors.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from lollipop.compat import iteritems
from lollipop.compat import iteritems, string_types


__all__ = [
Expand Down Expand Up @@ -137,7 +137,7 @@ def __init__(self):
self.errors = None

def _make_error(self, path, error):
parts = path.split('.', 1)
parts = path.split('.', 1) if isinstance(path, string_types) else [path]

if len(parts) == 1:
return {path: error}
Expand Down
26 changes: 22 additions & 4 deletions lollipop/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,20 +638,25 @@ class Dict(Type):
'foo': 'hello', 'bar': 123,
})
:param dict value_type: A single :class:`Type` for all dict values or mapping
of allowed keys to :class:`Type` instances.
:param dict value_types: A single :class:`Type` for all dict values or mapping
of allowed keys to :class:`Type` instances (defaults to :class:`Any`)
:param Type key_type: Type for dictionary keys (defaults to :class:`Any`).
Can be used to either transform or validate dictionary keys.
:param kwargs: Same keyword arguments as for :class:`Type`.
"""

default_error_messages = {
'invalid': 'Value should be dict',
}

def __init__(self, value_types=Any(), **kwargs):
def __init__(self, value_types=None, key_type=None, **kwargs):
super(Dict, self).__init__(**kwargs)
if isinstance(value_types, Type):
if value_types is None:
value_types = DictWithDefault(default=Any())
elif isinstance(value_types, Type):
value_types = DictWithDefault(default=value_types)
self.value_types = value_types
self.key_type = key_type or Any()

def load(self, data, *args, **kwargs):
if data is MISSING or data is None:
Expand All @@ -666,10 +671,16 @@ def load(self, data, *args, **kwargs):
value_type = self.value_types.get(k)
if value_type is None:
continue
try:
k = self.key_type.load(k, *args, **kwargs)
except ValidationError as ve:
errors_builder.add_error(k, ve.messages)

try:
result[k] = value_type.load(v, *args, **kwargs)
except ValidationError as ve:
errors_builder.add_error(k, ve.messages)

errors_builder.raise_errors()

return super(Dict, self).load(result, *args, **kwargs)
Expand All @@ -687,10 +698,17 @@ def dump(self, value, *args, **kwargs):
value_type = self.value_types.get(k)
if value_type is None:
continue

try:
k = self.key_type.dump(k, *args, **kwargs)
except ValidationError as ve:
errors_builder.add_error(k, ve.messages)

try:
result[k] = value_type.dump(v, *args, **kwargs)
except ValidationError as ve:
errors_builder.add_error(k, ve.messages)

errors_builder.raise_errors()

return super(Dict, self).dump(result, *args, **kwargs)
Expand Down
17 changes: 11 additions & 6 deletions lollipop/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,17 @@ def make_context_aware(func, numargs):
into another function that takes extra argument and drops it.
Used to support user providing callback functions that are not context aware.
"""
if inspect.ismethod(func):
arg_count = len(inspect.getargspec(func).args) - 1
elif inspect.isfunction(func):
arg_count = len(inspect.getargspec(func).args)
else:
arg_count = len(inspect.getargspec(func.__call__).args) - 1
try:
if inspect.ismethod(func):
arg_count = len(inspect.getargspec(func).args) - 1
elif inspect.isfunction(func):
arg_count = len(inspect.getargspec(func).args)
elif inspect.isclass(func):
arg_count = len(inspect.getargspec(func.__init__).args) - 1
else:
arg_count = len(inspect.getargspec(func.__call__).args) - 1
except TypeError:
arg_count = numargs

if arg_count <= numargs:
def normalized(*args):
Expand Down
53 changes: 53 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,14 @@ class TestDict(RequiredTestsMixin, ValidationTestsMixin):
valid_data = {'foo': 123, 'bar': 456}
valid_value = {'foo': 123, 'bar': 456}

def test_loading_dict_with_custom_key_type(self):
assert Dict(Any(), key_type=Integer())\
.load({'123': 'foo', '456': 'bar'}) == {123: 'foo', 456: 'bar'}

def test_loading_accepts_any_key_if_key_type_is_not_specified(self):
assert Dict(Any())\
.load({'123': 'foo', 456: 'bar'}) == {'123': 'foo', 456: 'bar'}

def test_loading_dict_with_values_of_the_same_type(self):
assert Dict(Integer()).load({'foo': 123, 'bar': 456}) == \
{'foo': 123, 'bar': 456}
Expand All @@ -618,11 +626,21 @@ def test_loading_dict_with_values_of_different_types(self):
assert Dict({'foo': Integer(), 'bar': String(), 'baz': Boolean()})\
.load(value) == value

def test_loading_accepts_any_value_if_value_types_are_not_specified(self):
assert Dict()\
.load({'foo': 'bar', 'baz': 123}) == {'foo': 'bar', 'baz': 123}

def test_loading_non_dict_value_raises_ValidationError(self):
with pytest.raises(ValidationError) as exc_info:
Dict(Integer()).load(['1', '2'])
assert exc_info.value.messages == Dict.default_error_messages['invalid']

def test_loading_dict_with_incorrect_key_value_raises_ValidationError(self):
with pytest.raises(ValidationError) as exc_info:
Dict(Any(), key_type=Integer()).load({'123': 'foo', 'bar': 'baz'})
assert exc_info.value.messages == \
{'bar': Integer.default_error_messages['invalid']}

def test_loading_dict_with_items_of_incorrect_type_raises_ValidationError(self):
with pytest.raises(ValidationError) as exc_info:
Dict(Integer()).load({'foo': 1, 'bar': 'abc'})
Expand All @@ -644,12 +662,28 @@ def validate(value):
validate=[constant_fail_validator(message1)]).load([1, 2, 3])
assert validate.called == 0

def test_loading_dict_with_incorrect_key_value_and_incorrect_value_raises_ValidationError_with_both_errors(self):
key_error = 'Key should be integer'
with pytest.raises(ValidationError) as exc_info:
Dict(String(), key_type=Integer(error_messages={'invalid': key_error}))\
.load({123: 'foo', 'bar': 456})
assert exc_info.value.messages == \
{'bar': [key_error, String.default_error_messages['invalid']]}

def test_loading_passes_context_to_inner_type_load(self):
inner_type = SpyType()
context = object()
Dict(inner_type).load({'foo': 123}, context)
assert inner_type.load_context == context

def test_dumping_dict_with_custom_key_type(self):
assert Dict(Any(), key_type=Transform(Integer(), post_dump=str))\
.dump({123: 'foo', 456: 'bar'}) == {'123': 'foo', '456': 'bar'}

def test_dumping_accepts_any_key_if_key_type_is_not_specified(self):
assert Dict(Any())\
.dump({'123': 'foo', 456: 'bar'}) == {'123': 'foo', 456: 'bar'}

def test_dumping_dict_with_values_of_the_same_type(self):
assert Dict(Integer()).dump({'foo': 123, 'bar': 456}) == \
{'foo': 123, 'bar': 456}
Expand All @@ -659,17 +693,36 @@ def test_dumping_dict_with_values_of_different_types(self):
assert Dict({'foo': Integer(), 'bar': String(), 'baz': Boolean()})\
.load(value) == value

def test_dumping_accepts_any_value_if_value_types_are_not_specified(self):
assert Dict()\
.dump({'foo': 'bar', 'baz': 123}) == {'foo': 'bar', 'baz': 123}

def test_dumping_non_dict_value_raises_ValidationError(self):
with pytest.raises(ValidationError) as exc_info:
Dict(()).dump('1, 2, 3')
assert exc_info.value.messages == Dict.default_error_messages['invalid']

def test_dumping_dict_with_incorrect_key_value_raises_ValidationError(self):
with pytest.raises(ValidationError) as exc_info:
Dict(Any(), key_type=Transform(Integer(), post_dump=str))\
.dump({123: 'foo', 'bar': 'baz'})
assert exc_info.value.messages == \
{'bar': Integer.default_error_messages['invalid']}

def test_dumping_dict_with_items_of_incorrect_type_raises_ValidationError(self):
with pytest.raises(ValidationError) as exc_info:
Dict(Integer()).dump({'foo': 1, 'bar': 'abc'})
message = Integer.default_error_messages['invalid']
assert exc_info.value.messages == {'bar': message}

def test_dumping_dict_with_incorrect_key_value_and_incorrect_value_raises_ValidationError_with_both_errors(self):
key_error = 'Key should be integer'
with pytest.raises(ValidationError) as exc_info:
Dict(String(), key_type=Integer(error_messages={'invalid': key_error}))\
.dump({123: 'foo', 'bar': 456})
assert exc_info.value.messages == \
{'bar': [key_error, String.default_error_messages['invalid']]}

def test_dumping_passes_context_to_inner_type_dump(self):
inner_type = SpyType()
context = object()
Expand Down
19 changes: 19 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def __call__(self, a, b, c):
return 42


class ObjClassDummy:
def __init__(self, a, b, c):
self.args = (a, b, c)


class TestCallWithContext:
def test_calls_function_with_given_arguments(self):
class NonLocal:
Expand Down Expand Up @@ -69,6 +74,20 @@ def test_calls_callable_object_with_extra_context_argument_if_function_accepts_m
call_with_context(obj, context, 1, 'foo')
assert obj.args == (1, 'foo', context)

def test_calls_class_with_given_arguments(self):
context = object()
result = call_with_context(ObjClassDummy, context, 1, 'foo', True)
assert result.args == (1, 'foo', True)

def test_calls_class_with_extra_context_argument_if_function_accepts_more_arguments_than_given(self):
context = object()
result = call_with_context(ObjClassDummy, context, 1, 'foo')
assert result.args == (1, 'foo', context)

def test_calls_builtin_with_given_arguments(self):
context = object()
assert call_with_context(str, context, 123) == '123'


class TestToCamelCase:
def test_converting_snake_case_to_camel_case(self):
Expand Down

0 comments on commit 823e493

Please sign in to comment.