From 368d549fa627f69d764d8ee277ff4b0bbdd5a8d8 Mon Sep 17 00:00:00 2001 From: Maxim Kulkin Date: Wed, 10 May 2017 15:01:56 -0700 Subject: [PATCH 1/2] Add OpenStruct - object-like dictionary implementation --- lollipop/compat.py | 5 ++ lollipop/utils.py | 59 +++++++++++++++ tests/test_utils.py | 172 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 235 insertions(+), 1 deletion(-) diff --git a/lollipop/compat.py b/lollipop/compat.py index e562944..373b3fa 100644 --- a/lollipop/compat.py +++ b/lollipop/compat.py @@ -22,5 +22,10 @@ if PY26: from .ordereddict import OrderedDict + from UserDict import DictMixin else: from collections import OrderedDict + try: + from collections import MutableMapping as DictMixin + except ImportError: + from collections.abc import MutableMapping as DictMixin diff --git a/lollipop/utils.py b/lollipop/utils.py index 38db135..596706c 100644 --- a/lollipop/utils.py +++ b/lollipop/utils.py @@ -1,5 +1,6 @@ import inspect import re +from lollipop.compat import DictMixin, iterkeys def identity(value): @@ -66,3 +67,61 @@ def to_snake_case(s): def to_camel_case(s): """Converts snake-case identifiers to camel-case.""" return re.sub('_([a-z])', lambda m: m.group(1).upper(), s) + + +class OpenStruct(DictMixin): + """A dictionary that also allows accessing values through object attributes.""" + def __init__(self, data=None): + self.__dict__.update({'_data': data or {}}) + + def __getitem__(self, key): + return self._data[key] + + def __setitem__(self, key, value): + self._data[key] = value + + def __delitem__(self, key): + del self._data[key] + + def __iter__(self): + for key in self._data: + yield key + + def __len__(self): + return len(self._data) + + def __contains__(self, key): + return key in self._data + + def keys(self): + return self._data.keys() + + def iterkeys(self): + for k in iterkeys(self._data): + yield k + + def iteritems(self): + for k, v in self._data.iteritems(): + yield k, v + + def __hasattr__(self, name): + return name in self._data + + def __getattr__(self, name): + if name not in self._data: + raise AttributeError(name) + return self._data[name] + + def __setattr__(self, name, value): + self._data[name] = value + + def __delattr__(self, name): + if name not in self._data: + raise AttributeError(name) + del self._data[name] + + def __repr__(self): + return '<%s %s>' % ( + self.__class__.__name__, + ' '.join('%s=%s' % (k, repr(v)) for k, v in self._data.iteritems()), + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index a22cffb..fad256b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,7 @@ +from lollipop.compat import iterkeys, itervalues, iteritems from lollipop.utils import call_with_context, to_camel_case, to_snake_case, \ - constant, identity + constant, identity, OpenStruct +import pytest class ObjMethodDummy: @@ -115,3 +117,171 @@ def test_constant_returns_function_that_takes_any_arguments_and_always_returns_g assert f(456) == 123 assert f(1, 3, 5) == 123 assert f(1, foo='bar') == 123 + + +class TestOpenStruct: + def test_getting_values(self): + o = OpenStruct({'foo': 'hello', 'bar': 123}) + assert o['foo'] == 'hello' + assert o['bar'] == 123 + + def test_getting_nonexisting_values_raises_KeyError(self): + o = OpenStruct({'foo': 'hello', 'bar': 123}) + + with pytest.raises(KeyError): + x = o['baz'] + + def test_setting_values(self): + o = OpenStruct({'foo': 'hello'}) + o['foo'] = 'goodbye' + o['bar'] = 111 + + assert o['foo'] == 'goodbye' + assert o['bar'] == 111 + + def test_contains(self): + o = OpenStruct({'foo': 'hello'}) + + assert 'foo' in o + assert 'bar' not in o + + o['bar'] = 123 + + assert 'foo' in o + assert 'bar' in o + + def test_deleting_values(self): + o = OpenStruct({'foo': 'hello'}) + + del o['foo'] + assert 'foo' not in o + + o['bar'] = 123 + del o['bar'] + assert 'bar' not in o + + def test_deleting_nonexisting_values_raises_KeyError(self): + o = OpenStruct({'foo': 'hello'}) + + with pytest.raises(KeyError): + del o['bar'] + + def test_keys(self): + assert sorted(OpenStruct().keys()) == [] + + o = OpenStruct({'foo': 1, 'bar': 2}) + assert sorted(o.keys()) == sorted(['foo', 'bar']) + + o['baz'] = 2 + assert sorted(o.keys()) == sorted(['foo', 'bar', 'baz']) + + def test_values(self): + assert sorted(OpenStruct().values()) == [] + + o = OpenStruct({'foo': 123}) + assert sorted(o.values()) == sorted([123]) + + o['bar'] = 456 + assert sorted(o.values()) == sorted([123, 456]) + + def test_items(self): + assert sorted(OpenStruct().items()) == [] + + o = OpenStruct({'foo': 'hello'}) + assert sorted(o.items()) == sorted([('foo', 'hello')]) + + o['bar'] = 123 + assert sorted(o.items()) == sorted([('foo', 'hello'), ('bar', 123)]) + + def test_iterkeys(self): + assert sorted(iterkeys(OpenStruct())) == [] + + o = OpenStruct({'foo': 123}) + assert sorted(iterkeys(o)) == sorted(o.keys()) + + o['bar'] = 456 + assert sorted(iterkeys(o)) == sorted(o.keys()) + + def test_itervalues(self): + assert sorted(itervalues(OpenStruct())) == [] + + o = OpenStruct({'foo': 'hello'}) + assert sorted(itervalues(o)) == sorted(o.values()) + + o['bar'] = 'howdy' + assert sorted(itervalues(o)) == sorted(o.values()) + + def test_iteritems(self): + assert sorted(iteritems(OpenStruct())) == [] + + o = OpenStruct({'foo': 'hello'}) + assert sorted(iteritems(o)) == sorted(o.items()) + + o['bar'] = 123 + assert sorted(iteritems(o)) == sorted(o.items()) + + def test_length(self): + assert len(OpenStruct()) == 0 + + o = OpenStruct({'foo': 'hello'}) + assert len(o) == 1 + + o['bar'] = 123 + assert len(o) == 2 + + def test_iter(self): + assert [x for x in OpenStruct()] == [] + + o = OpenStruct({'foo': 'hello'}) + assert sorted([x for x in o]) == sorted(o.keys()) + + o['bar'] = 123 + assert sorted([x for x in o]) == sorted(o.keys()) + + def test_hasattr(self): + o = OpenStruct({'foo': 'hello'}) + o['bar'] = 123 + + assert hasattr(o, 'foo') + assert hasattr(o, 'bar') + assert not hasattr(o, 'baz') + + def test_getattr(self): + o = OpenStruct({'foo': 'hello'}) + o['bar'] = 123 + + assert o.foo == 'hello' + assert o.bar == 123 + + def test_getattr_on_nonexisting_key_raises_AttributeError(self): + o = OpenStruct({'foo': 'hello'}) + + with pytest.raises(AttributeError): + x = o.baz + + def test_setattr(self): + o = OpenStruct({'foo': 'hello'}) + + o.foo = 'goodbye' + o.bar = 123 + + assert o.foo == 'goodbye' + assert o.bar == 123 + assert o['foo'] == o.foo + assert o['bar'] == o.bar + + def test_delattr(self): + o = OpenStruct({'foo': 'hello'}) + o['bar'] = 123 + + del o.foo + assert not hasattr(o, 'foo') + + del o.bar + assert not hasattr(o, 'bar') + + def test_delattr_on_nonexisting_key_raises_AttributeError(self): + o = OpenStruct() + + with pytest.raises(AttributeError): + del o.foo From eda66e6e2a3dcaa92046b25c30b0c218fd92462e Mon Sep 17 00:00:00 2001 From: Maxim Kulkin Date: Wed, 10 May 2017 15:02:56 -0700 Subject: [PATCH 2/2] Update Object type default constructor to return object-like value --- lollipop/types.py | 9 ++++++--- tests/test_types.py | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lollipop/types.py b/lollipop/types.py index 10f254f..5f3ac70 100644 --- a/lollipop/types.py +++ b/lollipop/types.py @@ -1,6 +1,7 @@ from lollipop.errors import ValidationError, ValidationErrorBuilder, \ ErrorMessagesMixin, merge_errors -from lollipop.utils import is_list, is_dict, make_context_aware, constant, identity +from lollipop.utils import is_list, is_dict, make_context_aware, \ + constant, identity, OpenStruct from lollipop.compat import string_types, int_types, iteritems, OrderedDict import datetime @@ -1186,8 +1187,10 @@ def load(self, data, *args, **kwargs): errors_builder.raise_errors() result = super(Object, self).load(result, *args, **kwargs) - if self.constructor: - result = self.constructor(**result) + + result = self.constructor(**result) \ + if self.constructor else OpenStruct(result) + return result def load_into(self, obj, data, inplace=True, *args, **kwargs): diff --git a/tests/test_types.py b/tests/test_types.py index bc94267..2ab07e1 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1526,6 +1526,13 @@ def test_loading_passes_context_to_inner_type_load(self): assert foo_type.load_context == context assert bar_type.load_context == context + def test_constructing_objects_with_default_constructor_on_load(self): + result = Object({'foo': String(), 'bar': Integer()})\ + .load({'foo': 'hello', 'bar': 123}) + + assert result.foo == 'hello' + assert result.bar == 123 + def test_constructing_custom_objects_on_load(self): MyData = namedtuple('MyData', ['foo', 'bar']) assert Object({'foo': String(), 'bar': Integer()}, constructor=MyData)\