From 481b15856c6669ffe369541dc5b73ed55afafdf2 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 6 Apr 2016 14:37:46 -0400 Subject: [PATCH 1/5] Added coverage for serializing fields and models --- tests/test_fields.py | 69 +++++++++++++++++++++++++++++++++++++++++++- tests/test_models.py | 20 +++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/tests/test_fields.py b/tests/test_fields.py index 518af4a..c9882de 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -3,8 +3,75 @@ from rest_orm import errors, fields, models +import pytest -class FieldsTestCase(TestCase): + +class SerializeFieldsTestCase(TestCase): + """Serialization test case.""" + + def test_serialize_deeply_nested_field(self): + """Test serializing to an arbitrarily nested depth.""" + field = fields.AdaptedField('[x][y][0][z]') + + data = field.serialize('hello world') + self.assertTrue(data['x']['y'][0]['z'] == 'hello world') + + data = field.serialize('hello world', {'x': {'y': [{'q': 'hi'}]}}) + self.assertTrue(data['x']['y'][0]['z'] == 'hello world') + + def test_serialize_exact_position_field(self): + """Test serializing to an exact list position.""" + field = fields.AdaptedField('[test][1][z]') + + data = field.serialize('hello world') + self.assertTrue(data['test'][0] is None) + self.assertTrue(data['test'][1]['z'] == 'hello world') + + data = field.serialize('hello world', {'test': [{'q': 'a'}]}) + self.assertTrue(data['test'][1]['z'] == 'hello world') + + def test_serialize_invalid_route(self): + """Test serializing through a non-dict route.""" + field = fields.AdaptedField('[x][y][0][z]') + + with pytest.raises(ValueError) as excinfo: + field.serialize('hello world', {'x': 5}) + assert 'Invalid serialization target.' in str(excinfo) + + def test_serialize_occupied_route(self): + """Test serializing to an occupied endpoint.""" + field = fields.AdaptedField('[x][z]') + + with pytest.raises(ValueError) as excinfo: + field.serialize('hello world', {'x': {'z': 5}}) + assert 'Invalid serialization target.' in str(excinfo) + + def test_serialize_occupied_position(self): + """Test serializing to a non-dict occupied position.""" + field = fields.AdaptedField('[0]') + + with pytest.raises(ValueError) as excinfo: + field.serialize('hello world', ['test']) + assert 'Position occupied.' in str(excinfo) + + def test_serialize_non_list_like_field(self): + """Test serializing a list when occupied by non-listlike value.""" + field = fields.AdaptedField('[x][0][z]') + with pytest.raises(ValueError) as excinfo: + field.serialize('hello world', {'x': {'hello': 'world'}}) + assert 'Object is not list-like.' in str(excinfo) + + def test_serialize_negative_position(self): + """Test serializing a negative position coordinate.""" + field = fields.AdaptedField('[x][-1][z]') + + with pytest.raises(ValueError) as excinfo: + field.serialize('hello world', {'x': []}) + assert 'Invalid serialization position.' in str(excinfo) + + +class DeserializeFieldsTestCase(TestCase): + """Deserialization test case.""" def test_deserialize_deeply_nested_field(self): """Test deserializing a heavily nested field.""" diff --git a/tests/test_models.py b/tests/test_models.py index d5e2c81..80cd863 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,6 +6,9 @@ class TestModel(models.AdaptedModel): first = fields.AdaptedString('[first]') + address1 = fields.AdaptedString('[address][address][0]') + address2 = fields.AdaptedString('[address][address][1]') + city = fields.AdaptedString('[address][city]') def make_request(self): return '{"first": "First Name"}' @@ -35,3 +38,20 @@ def test_model_post_load(self): """Test post-load actions created from the post_load method.""" model = TestModel().load({"first": "First Name"}) self.assertTrue(model.full_name == 'First Name Last Name') + + def test_model_dump(self): + """Test serialized a dictionary into the specified field paths.""" + expected = { + 'first': 'Test', + 'address': { + 'city': 'Arden', + 'address': ['100 Harvard Street', '#801B'] + } + } + actual = TestModel().dump({ + 'first': 'Test', + 'address1': '100 Harvard Street', + 'address2': '#801B', + 'city': 'Arden' + }) + self.assertTrue(actual == expected) From 56a4ca8452f467214db37fc406a0d2f2cbedfc7f Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 6 Apr 2016 14:37:54 -0400 Subject: [PATCH 2/5] Added pytest requirement --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index baf4151..04a3ce3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ sphinx sphinx-autobuild sphinx-rtd-theme -e git+https://github.com/caxiam/model-api.git#egg=Package +pytest==2.8.0 From 86b0434c6d87fb74c821caeb339d294864a1873d Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 6 Apr 2016 14:38:25 -0400 Subject: [PATCH 3/5] Added serialize method with various helpers to ensure data integrity --- rest_orm/fields.py | 75 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/rest_orm/fields.py b/rest_orm/fields.py index 335a250..eeccabd 100644 --- a/rest_orm/fields.py +++ b/rest_orm/fields.py @@ -38,7 +38,7 @@ def deserialize(self, data): return self._deserialize(data) try: - raw_value = self.map_from_string(self.path, data) + raw_value = self._map_from_string(self.path, data) except (KeyError, IndexError): if self.required: raise KeyError('{} not found.'.format(self.path)) @@ -60,7 +60,7 @@ def _validate(self, value): self.validate(value) return None - def map_from_string(self, path, data): + def _map_from_string(self, path, data): """Return nested value from the string path taken. :param path: A string path to the value. E.g. [name][first][0]. @@ -76,6 +76,77 @@ def extract_by_type(path): data = extract_by_type(path) return data + def serialize(self, value, obj={}): + """Using `path`, structure an object into the required output.""" + if self.path is None: + raise ValueError('Value can not be serialized.') + return self._assign_from_keys(value, self.path[1:-1].split(']['), obj) + + def _is_integer(self, key): + """Determine if the key is an integer.""" + try: + int(key) + return True + except ValueError: + return False + + def _build_from_keys(self, keys, value): + """Build the data structure bottom up from a set of keys.""" + for key in reversed(keys): + if self._is_integer(key): + response = [] + while len(response) < int(key): + response.append(None) + response.append(value) + value = response + else: + value = {key: value} + return value + + def _assign_to_position(self, position, value, array, keys=[]): + """Assign a value to its specified list position.""" + if position < 0: + raise ValueError('Invalid serialization position.') + + while len(array) < position + 1: + array.append(None) + + array_value = array[position] + if array_value is not None: + if isinstance(array_value, dict): + array = [self._assign_from_keys(value, keys, array_value)] + else: + raise ValueError('Position occupied.') + else: + array[position] = self._build_from_keys(keys, value) + + return array + + def _assign_from_keys(self, value, keys=[], obj={}): + """Get or create a key, value pair.""" + key = keys[0] + if isinstance(obj, dict): + if self._is_integer(key): + raise ValueError('Object is not list-like.') + elif key in obj: + if len(keys) == 1: + raise ValueError('Invalid serialization target.') + obj = {key: self._assign_from_keys(value, keys[1:], obj[key])} + return obj + else: + obj.update(self._build_from_keys(keys, value)) + return obj + elif isinstance(obj, list): + if self._is_integer(key): + return self._assign_to_position(int(key), value, obj, keys[1:]) + else: + raise ValueError('Invalid serialization target.') + elif obj is None: + obj = self._build_from_keys(keys, value) + return obj + else: + raise ValueError('Invalid serialization target.') + class AdaptedBoolean(AdaptedField): """Parse an adapted field into the boolean type.""" From bb37381735a0e98058b15f20d6ce58af5d3108bb Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 6 Apr 2016 14:38:37 -0400 Subject: [PATCH 4/5] Added dump method --- rest_orm/models.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/rest_orm/models.py b/rest_orm/models.py index 14575b3..741a233 100644 --- a/rest_orm/models.py +++ b/rest_orm/models.py @@ -29,6 +29,16 @@ def load(self, response): self.post_load() return self + def dump(self, data): + """Structure a flat dictionary into a nested output.""" + response = {} + for field_name, value in data.iteritems(): + field = getattr(self, field_name) + if not isinstance(field, AdaptedField): + continue + response = field.serialize(value, response) + return response + def post_load(self): """Perform any model level actions after load.""" pass From 58431d10a7ab7b8fe8d8617cb72c33d3af966fc2 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Wed, 6 Apr 2016 17:51:02 -0400 Subject: [PATCH 5/5] Added documentation --- rest_orm/fields.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/rest_orm/fields.py b/rest_orm/fields.py index eeccabd..20382d4 100644 --- a/rest_orm/fields.py +++ b/rest_orm/fields.py @@ -83,7 +83,7 @@ def serialize(self, value, obj={}): return self._assign_from_keys(value, self.path[1:-1].split(']['), obj) def _is_integer(self, key): - """Determine if the key is an integer.""" + """Return `True` if the provided key can be parsed as an integer.""" try: int(key) return True @@ -92,11 +92,20 @@ def _is_integer(self, key): def _build_from_keys(self, keys, value): """Build the data structure bottom up from a set of keys.""" + # Reversing the keys is important because it allows us to + # naively wrap values with the desired data-type. We don't + # worry about what key to assign to. for key in reversed(keys): + # If the key is an integer, the value needs to be enclosed + # in a list, otherwise it is enclosed in a dictionary if self._is_integer(key): + # Because position is important for some APIs, we need + # to pad out the list up to the point the key is + # supposed to be position. response = [] while len(response) < int(key): response.append(None) + # We append the value at its appropriate list position. response.append(value) value = response else: @@ -106,13 +115,26 @@ def _build_from_keys(self, keys, value): def _assign_to_position(self, position, value, array, keys=[]): """Assign a value to its specified list position.""" if position < 0: + # We do not support assigning to the end of a list. + # Because the "end" of the list is entirely based on the + # operation's processing order, negatives are excluded from + # serialized. raise ValueError('Invalid serialization position.') + # Pad the list up to, and including, the specified list + # position. This is important because this method does not + # append values to lists. It instead assigns or updates + # existing values to the new value. while len(array) < position + 1: array.append(None) + # If the `array_value` is a padded value, we can replace it + # without caring about its existing value. array_value = array[position] if array_value is not None: + # If the value is a dictionary, we can update the dictionary + # with a new key, value pair. Any other value would require + # us to overwrite data. if isinstance(array_value, dict): array = [self._assign_from_keys(value, keys, array_value)] else: @@ -127,24 +149,40 @@ def _assign_from_keys(self, value, keys=[], obj={}): key = keys[0] if isinstance(obj, dict): if self._is_integer(key): + # Dictionaries can not have integer keys. raise ValueError('Object is not list-like.') elif key in obj: if len(keys) == 1: + # If the key is in the object but is also the last key + # available for traversal, then an overwrite would be + # necessary to continue. raise ValueError('Invalid serialization target.') + # Recursively call the function, trimming the used key, + # and descending into the object by the current key. obj = {key: self._assign_from_keys(value, keys[1:], obj[key])} return obj else: + # If the key is not present in the dictionary, we can + # update the dictionary with a new set of values without + # worrying about overwriting data. obj.update(self._build_from_keys(keys, value)) return obj elif isinstance(obj, list): if self._is_integer(key): + # Lists terminate recursive execution. However, the + # `_assign_to_postition` method may call the + # `_assign_from_keys` method if the object contains a + # dictionary value at the specified position. return self._assign_to_position(int(key), value, obj, keys[1:]) else: + # List positions are not string like. Someone goofed. raise ValueError('Invalid serialization target.') elif obj is None: + # `None` type values are overwritten. obj = self._build_from_keys(keys, value) return obj else: + # Some value occupies the desired position. raise ValueError('Invalid serialization target.')