Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
113 changes: 111 additions & 2 deletions rest_orm/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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].
Expand All @@ -76,6 +76,115 @@ 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):
"""Return `True` if the provided key can be parsed as 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."""
# 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:
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:
# 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:
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):
# 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.')


class AdaptedBoolean(AdaptedField):
"""Parse an adapted field into the boolean type."""
Expand Down
10 changes: 10 additions & 0 deletions rest_orm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 68 additions & 1 deletion tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
20 changes: 20 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}'
Expand Down Expand Up @@ -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)