From 1656c3f41dfc8d278206057fb7b988d367250139 Mon Sep 17 00:00:00 2001 From: Maxim Kulkin Date: Tue, 7 Mar 2017 15:36:11 -0800 Subject: [PATCH] Add 'name' and 'description' attributes to all types --- lollipop/types.py | 76 +++++++++++++++++++++++---------------------- tests/test_types.py | 65 ++++++++++++++++++++++++++++---------- 2 files changed, 88 insertions(+), 53 deletions(-) diff --git a/lollipop/types.py b/lollipop/types.py index 2dfce60..db46983 100644 --- a/lollipop/types.py +++ b/lollipop/types.py @@ -83,13 +83,15 @@ class Type(ErrorMessagesMixin, object): 'required': 'Value is required', } - def __init__(self, validate=None, *args, **kwargs): + def __init__(self, name=None, description=None, validate=None, *args, **kwargs): super(Type, self).__init__(*args, **kwargs) if validate is None: validate = [] elif callable(validate): validate = [validate] + self.name = name + self.description = description self._validators = ValidatorCollection(validate) def validate(self, data, context=None): @@ -1298,8 +1300,36 @@ def __repr__(self): ) -class Optional(Type): - """A wrapper type which makes values optional: if value is missing or None, +class Modifier(Type): + """Base class for modifiers - a wrapper for types that modify + how those types work. Also, it tries to be as transparent as possible + in regard to inner type, so it proxies all unknown attributes to inner type. + + :param Type inner_type: Actual type that should be optional. + """ + def __init__(self, inner_type, **kwargs): + super(Modifier, self).__init__( + **dict({'name': inner_type.name, + 'description': inner_type.description}, + **kwargs) + ) + self.inner_type = inner_type + + def __hasattr__(self, name): + return hasattr(self.inner_type, name) + + def __getattr__(self, name): + return getattr(self.inner_type, name) + + def __repr__(self): + return '<{klass} {inner_type}>'.format( + klass=self.__class__.__name__, + inner_type=repr(self.inner_type), + ) + + +class Optional(Modifier): + """A modifier which makes values optional: if value is missing or None, it will not transform it with an inner type but instead will return None (or any other configured value). @@ -1326,8 +1356,7 @@ class Optional(Type): def __init__(self, inner_type, load_default=None, dump_default=None, **kwargs): - super(Optional, self).__init__(**kwargs) - self.inner_type = inner_type + super(Optional, self).__init__(inner_type, **kwargs) if not callable(load_default): load_default = constant(load_default) if not callable(dump_default): @@ -1358,7 +1387,7 @@ def __repr__(self): ) -class LoadOnly(Type): +class LoadOnly(Modifier): """A wrapper type which proxies loading to inner type but always returns :obj:`MISSING` on dump. @@ -1371,24 +1400,14 @@ class LoadOnly(Type): :param Type inner_type: Data type. """ - def __init__(self, inner_type): - super(LoadOnly, self).__init__() - self.inner_type = inner_type - def load(self, data, *args, **kwargs): return self.inner_type.load(data, *args, **kwargs) - def dump(self, data, context=None): + def dump(self, data, *args, **kwargs): return MISSING - def __repr__(self): - return '<{klass} {inner_type}>'.format( - klass=self.__class__.__name__, - inner_type=repr(self.inner_type), - ) - -class DumpOnly(Type): +class DumpOnly(Modifier): """A wrapper type which proxies dumping to inner type but always returns :obj:`MISSING` on load. @@ -1401,24 +1420,14 @@ class DumpOnly(Type): :param Type inner_type: Data type. """ - def __init__(self, inner_type): - super(DumpOnly, self).__init__() - self.inner_type = inner_type - def load(self, data, *args, **kwargs): return MISSING def dump(self, data, *args, **kwargs): return self.inner_type.dump(data, *args, **kwargs) - def __repr__(self): - return '<{klass} {inner_type}>'.format( - klass=self.__class__.__name__, - inner_type=repr(self.inner_type), - ) - -class Transform(Type): +class Transform(Modifier): """A wrapper type which allows us to convert data structures to an inner type, then loaded or dumped with a customized format. @@ -1455,8 +1464,7 @@ class Transform(Type): def __init__(self, inner_type, pre_load=identity, post_load=identity, pre_dump=identity, post_dump=identity): - super(Transform, self).__init__() - self.inner_type = inner_type + super(Transform, self).__init__(inner_type) self.pre_load = make_context_aware(pre_load, 1) self.post_load = make_context_aware(post_load, 1) self.pre_dump = make_context_aware(pre_dump, 1) @@ -1480,12 +1488,6 @@ def dump(self, value, context=None): context, ) - def __repr__(self): - return '<{klass} {inner_type}>'.format( - klass=self.__class__.__name__, - inner_type=repr(self.inner_type), - ) - def validated_type(base_type, name=None, validate=None): """Convenient way to create a new type by adding validation to existing type. diff --git a/tests/test_types.py b/tests/test_types.py index 7eabf5e..bc94267 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -55,7 +55,7 @@ def __call__(self, value, context=None): class SpyType(Type): def __init__(self, load_result=None, dump_result=None): - super(Type, self).__init__() + super(SpyType, self).__init__() self.loaded = None self.load_called = False self.load_context = None @@ -93,9 +93,21 @@ def load_into(self, obj, data, context=None, *args, **kwargs): return self.load_into_result or data +class NameDescriptionTestsMixin(object): + """Mixin that adds tests for adding name and description for type. + Host class should define `tested_type` properties. + """ + def test_name(self): + assert self.tested_type(name='foo').name == 'foo' + + def test_description(self): + assert self.tested_type(description='Just a description').description \ + == 'Just a description' + + class RequiredTestsMixin: """Mixin that adds tests for reacting to missing/None values during load/dump. - Host class should define `tested_type` properties. + Host class should define `tested_type` property. """ def test_loading_missing_value_raises_required_error(self): with pytest.raises(ValidationError) as exc_info: @@ -169,7 +181,7 @@ def test_validation_returns_combined_errors_if_multiple_validators_fail(self): .validate(self.valid_data) == [message1, message2] -class TestString(RequiredTestsMixin, ValidationTestsMixin): +class TestString(NameDescriptionTestsMixin, RequiredTestsMixin, ValidationTestsMixin): tested_type = String valid_data = 'foo' valid_value = 'foo' @@ -191,7 +203,7 @@ def test_dumping_non_string_value_raises_ValidationError(self): assert exc_info.value.messages == String.default_error_messages['invalid'] -class TestNumber(RequiredTestsMixin, ValidationTestsMixin): +class TestNumber(NameDescriptionTestsMixin, RequiredTestsMixin, ValidationTestsMixin): tested_type = Number valid_data = 1.23 valid_value = 1.23 @@ -257,7 +269,7 @@ def test_dumping_non_numeric_value_raises_ValidationError(self): assert exc_info.value.messages == Float.default_error_messages['invalid'] -class TestBoolean(RequiredTestsMixin, ValidationTestsMixin): +class TestBoolean(NameDescriptionTestsMixin, RequiredTestsMixin, ValidationTestsMixin): tested_type = Boolean valid_data = True valid_value = True @@ -281,7 +293,7 @@ def test_dumping_non_boolean_value_raises_ValidationError(self): assert exc_info.value.messages == Boolean.default_error_messages['invalid'] -class TestDateTime(RequiredTestsMixin, ValidationTestsMixin): +class TestDateTime(NameDescriptionTestsMixin, RequiredTestsMixin, ValidationTestsMixin): tested_type = DateTime valid_data = '2016-07-28T11:22:33UTC' valid_value = datetime.datetime(2016, 7, 28, 11, 22, 33) @@ -357,7 +369,7 @@ def test_customizing_error_message_if_value_is_not_string(self): assert exc_info.value.messages == 'Data 123 should be string' -class TestDate(RequiredTestsMixin, ValidationTestsMixin): +class TestDate(NameDescriptionTestsMixin, RequiredTestsMixin, ValidationTestsMixin): tested_type = Date valid_data = '2016-07-28' valid_value = datetime.date(2016, 7, 28) @@ -425,7 +437,7 @@ def test_customizing_error_message_if_value_is_not_string(self): assert exc_info.value.messages == 'Data 123 should be string' -class TestTime(RequiredTestsMixin, ValidationTestsMixin): +class TestTime(NameDescriptionTestsMixin, RequiredTestsMixin, ValidationTestsMixin): tested_type = Time valid_data = '11:22:33' valid_value = datetime.time(11, 22, 33) @@ -487,7 +499,7 @@ def test_customizing_error_message_if_value_is_not_string(self): assert exc_info.value.messages == 'Data 123 should be string' -class TestList(RequiredTestsMixin, ValidationTestsMixin): +class TestList(NameDescriptionTestsMixin, RequiredTestsMixin, ValidationTestsMixin): tested_type = partial(List, String()) valid_data = ['foo', 'bar'] valid_value = ['foo', 'bar'] @@ -548,7 +560,7 @@ def test_dumping_passes_context_to_inner_type_dump(self): assert inner_type.dump_context == context -class TestTuple(RequiredTestsMixin, ValidationTestsMixin): +class TestTuple(NameDescriptionTestsMixin, RequiredTestsMixin, ValidationTestsMixin): tested_type = partial(Tuple, [Integer(), Integer()]) valid_data = [123, 456] valid_value = [123, 456] @@ -604,7 +616,7 @@ def test_dumping_tuple_passes_context_to_inner_type_dump(self): assert inner_type.dump_context == context -class TestDict(RequiredTestsMixin, ValidationTestsMixin): +class TestDict(NameDescriptionTestsMixin, RequiredTestsMixin, ValidationTestsMixin): tested_type = partial(Dict, Integer()) valid_data = {'foo': 123, 'bar': 456} valid_value = {'foo': 123, 'bar': 456} @@ -1308,7 +1320,9 @@ def test_dumping_passes_context_to_field_type_dump(self): assert field_type.dump_context == context -class TestConstant: +class TestConstant(NameDescriptionTestsMixin): + tested_type = partial(Constant, 42) + def test_loading_always_returns_missing(self): assert Constant(42).load(42) == MISSING @@ -1379,7 +1393,7 @@ def load_into(self, obj, name, data, inplace=True, context=None): self.load_into_context = context -class TestObject(RequiredTestsMixin, ValidationTestsMixin): +class TestObject(NameDescriptionTestsMixin, RequiredTestsMixin, ValidationTestsMixin): tested_type = partial(Object, {'foo': String(), 'bar': Integer()}) valid_data = {'foo': 'hello', 'bar': 123} valid_value = {'foo': 'hello', 'bar': 123} @@ -2077,7 +2091,22 @@ def generate_value(self, context): assert spy.context is context -class TestLoadOnly: +class ProxyNameDescriptionTestsMixin(object): + """Mixin that adds tests for proxying name and description attributes + to an inner type. + Host class should define `tested_type` properties. + """ + def test_name(self): + assert self.tested_type(Type(name='foo')).name == 'foo' + + def test_description(self): + assert self.tested_type(Type(description='Just a description')).description \ + == 'Just a description' + + +class TestLoadOnly(ProxyNameDescriptionTestsMixin): + tested_type = LoadOnly + def test_loading_returns_inner_type_load_result(self): inner_type = SpyType(load_result='bar') assert LoadOnly(inner_type).load('foo') == 'bar' @@ -2098,7 +2127,9 @@ def test_dumping_does_not_call_inner_type_dump(self): assert not inner_type.dump_called -class TestDumpOnly: +class TestDumpOnly(ProxyNameDescriptionTestsMixin): + tested_type = DumpOnly + def test_loading_always_returns_missing(self): assert DumpOnly(Any()).load('foo') == MISSING @@ -2119,7 +2150,9 @@ def test_dumping_passes_context_to_inner_type_dump(self): assert inner_type.dump_context == context -class TestTransform: +class TestTransform(ProxyNameDescriptionTestsMixin): + tested_type = Transform + def test_loading_calls_pre_load_with_original_value(self): class Callbacks(): def pre_load(self, data):