diff --git a/requirements.txt b/requirements.txt index baf4151..83762c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,4 @@ nose==1.3.7 -wsgiref==0.1.2 sphinx sphinx-autobuild -sphinx-rtd-theme --e git+https://github.com/caxiam/model-api.git#egg=Package +sphinx-rtd-theme \ No newline at end of file diff --git a/rest_orm/__init__.py b/rest_orm/__init__.py index 8ea4f1a..ddbdf0e 100644 --- a/rest_orm/__init__.py +++ b/rest_orm/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- -import errors -import fields -import models +from rest_orm import errors +from rest_orm import fields +from rest_orm import models diff --git a/rest_orm/fields.py b/rest_orm/fields.py index 335a250..cb6ac32 100644 --- a/rest_orm/fields.py +++ b/rest_orm/fields.py @@ -1,33 +1,26 @@ # -*- coding: utf-8 -*- from datetime import datetime -from decimal import Decimal +from decimal import Decimal as PyDecimal from rest_orm.utils import get_class -class AdaptedField(object): +class Field(object): """Flat representaion of remote endpoint's field. - `AdaptedField` and its child classes are self-destructive. Once + `Field` and its child classes are self-destructive. Once deserialization is complete, the instance is replaced by the typed value retrieved. """ - def __init__(self, path, missing=None, nullable=True, required=False, - validate=None): + def __init__(self, path, missing=None): """Key extraction strategy and settings. :param path: A formattable string path. :param missing: The default deserialization value. - :param nullable: If `False`, disallow `None` type values. - :param required: If `True`, raise an error if the key is missing. - :param validate: A callable object. """ self.path = path self.missing = missing - self.nullable = nullable - self.required = required - self.validate = validate def deserialize(self, data): """Extract a value from the provided data object. @@ -38,28 +31,16 @@ def deserialize(self, data): return self._deserialize(data) try: - raw_value = self.map_from_string(self.path, data) + value = self.map_from_string(self.path, data) except (KeyError, IndexError): - if self.required: - raise KeyError('{} not found.'.format(self.path)) value = self.missing - else: - if raw_value is None and self.nullable: - value = None - else: - value = self._deserialize(raw_value) - - self._validate(value) - return value + if value is None: + return value + return self._deserialize(value) def _deserialize(self, value): return value - def _validate(self, value): - if self.validate is not None: - self.validate(value) - return None - def map_from_string(self, path, data): """Return nested value from the string path taken. @@ -77,50 +58,60 @@ def extract_by_type(path): return data -class AdaptedBoolean(AdaptedField): +class Boolean(Field): """Parse an adapted field into the boolean type.""" def _deserialize(self, value): return bool(value) -class AdaptedDate(AdaptedField): +class Date(Field): """Parse an adapted field into the datetime type.""" def __init__(self, *args, **kwargs): self.date_format = kwargs.pop('date_format', '%Y-%m-%d') - super(AdaptedDate, self).__init__(*args, **kwargs) + super(Date, self).__init__(*args, **kwargs) def _deserialize(self, value): return datetime.strptime(value, self.date_format) -class AdaptedDecimal(AdaptedField): +class Dump(Field): + """Return a pre-determined value.""" + + def __init__(self, value): + self.value = value + + def deserialize(self, data): + return self.value + + +class Decimal(Field): """Parse an adapted field into the decimal type.""" def _deserialize(self, value): - return Decimal(value) + return PyDecimal(value) -class AdaptedInteger(AdaptedField): +class Integer(Field): """Parse an adapted field into the integer type.""" def _deserialize(self, value): return int(value) -class AdaptedFunction(AdaptedField): +class Function(Field): """Parse an adapted field into a specified function's output.""" def __init__(self, f, *args, **kwargs): self.f = f - super(AdaptedFunction, self).__init__(*args, **kwargs) + super(Function, self).__init__(*args, **kwargs) def _deserialize(self, value): return self.f(value) -class AdaptedList(AdaptedField): +class List(Field): """Parse an adapted field into the list type.""" def _deserialize(self, value): @@ -129,20 +120,20 @@ def _deserialize(self, value): return value -class AdaptedNested(AdaptedField): - """Parse an adatped field into the AdaptedModel type.""" +class Nested(Field): + """Parse an adatped field into the Model type.""" def __init__(self, model, *args, **kwargs): - """Parse a list of nested objects into an AdaptedModel. + """Parse a list of nested objects into an Model. - :param model: AdaptedModel name or reference. + :param model: Model name or reference. """ self.nested_model = model - super(AdaptedNested, self).__init__(*args, **kwargs) + super(Nested, self).__init__(*args, **kwargs) @property def model(self): - """Return an AdaptedModel reference.""" + """Return an Model reference.""" if isinstance(self.nested_model, str): return get_class(self.nested_model) return self.nested_model @@ -153,7 +144,7 @@ def _deserialize(self, value): return self.model().load(value) -class AdaptedString(AdaptedField): +class String(Field): """Parse an adapted field into the string type.""" def _deserialize(self, value): diff --git a/rest_orm/models.py b/rest_orm/models.py index 14575b3..1f4e1f7 100644 --- a/rest_orm/models.py +++ b/rest_orm/models.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- -from rest_orm.fields import AdaptedField +from copy import copy + +from rest_orm.fields import Field from rest_orm.utils import ModelRegistry import json @@ -11,34 +13,33 @@ class BaseModel(object): __metaclass__ = ModelRegistry -class AdaptedModel(BaseModel): +class Model(BaseModel): """A flat representation of a single remote endpoint.""" + def loads(self, data): + """Load a JSON string to a flattened dictionary.""" + return self.load(json.loads(data)) + + def load(self, data): + """Flatten a nested dictionary.""" + response = {} + for field_name in dir(self): + field = getattr(self, field_name) + if not isinstance(field, Field): + continue + response[field_name] = copy(field.deserialize(data)) + + response = self.post_load(response) + return response + def connect(self, *args, **kwargs): """Make a request to a remote endpoint and load its JSON response.""" response = self.make_request(*args, **kwargs) return self.loads(response) - def loads(self, response): - """Marshal a JSON response object into the model.""" - return self.load(json.loads(response)) - - def load(self, response): - """Marshal a python dictionary object into the model.""" - self._do_load(response) - self.post_load() - return self - - def post_load(self): + def post_load(self, data): """Perform any model level actions after load.""" - pass - - def _do_load(self, data): - for field_name in dir(self): - field = getattr(self, field_name) - if not isinstance(field, AdaptedField): - continue - setattr(self, field_name, field.deserialize(data)) + return data def make_request(self): """Return the response data of a remote endpoint.""" diff --git a/tests/test_fields.py b/tests/test_fields.py index 518af4a..86240a6 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,119 +1,103 @@ # -*- coding: utf-8 -*- -from unittest import TestCase +from decimal import Decimal, InvalidOperation +from unittest import TestCase from rest_orm import errors, fields, models -class FieldsTestCase(TestCase): +class FieldTestCase(TestCase): - def test_deserialize_deeply_nested_field(self): - """Test deserializing a heavily nested field.""" - field = fields.AdaptedField('[x][y][z]') - value = field.deserialize({'x': {'y': {'z': 'value'}}}) + def test_deserialize_missing_value(self): + """Test replacing a missing value with the specified default.""" + field = fields.Field('[key]', missing='value') + value = field.deserialize({}) self.assertTrue(value == 'value') - def test_deserialize_list_item(self): - """Test deserializing a list item.""" - field = fields.AdaptedField('[1]') - value = field.deserialize([1, 2, 3]) - self.assertTrue(value == 2) - def test_validate_required(self): - """Test validating a missing value when required is `True`.""" - field = fields.AdaptedField('[x]', required=True) - try: - field.deserialize({'z': 1}) - self.assertTrue(False) - except KeyError: - self.assertTrue(True) +class BooleanTestCase(TestCase): + + def test_deserialize_value(self): + field = fields.Boolean('[key]') + value = field.deserialize({'key': 1}) + self.assertTrue(value is True) - def test_replace_missing_value(self): - """Test replacing a missing value with the specified default.""" - field = fields.AdaptedField('[key]', missing='value') - value = field.deserialize({'x': 1}) - self.assertTrue(value == 'value') - def test_nullable_field(self): - """Test skipping validation when field is `None`.""" - field = fields.AdaptedDate('[key]', nullable=True) - value = field.deserialize({'key': None}) - self.assertTrue(value is None) +class DateTestCase(TestCase): - def test_validate_deserialized_value(self): - """Test validating a deserialized value.""" - def validate_input(value): - if value > 10: - return - raise errors.AdapterError('Invalid value.') + def test_deserialize_value(self): + field = fields.Date('[key]') + value = field.deserialize({'key': '2015-01-31'}) + self.assertTrue(value.year == 2015) + self.assertTrue(value.month == 1) + self.assertTrue(value.day == 31) - field = fields.AdaptedField('[x]', validate=validate_input) + def test_deserialize_formatted_value(self): + field = fields.Date('[key]', date_format='%m/%d/%Y') + value = field.deserialize({'key': '01/31/2015'}) + self.assertTrue(value.year == 2015) + self.assertTrue(value.month == 1) + self.assertTrue(value.day == 31) + + def test_deserialize_invalid_value(self): try: - field.deserialize({'x': 5}) + fields.Date('[key]').deserialize({'key': 'AB'}) self.assertTrue(False) - except errors.AdapterError: + except ValueError: self.assertTrue(True) - value = field.deserialize({'x': 11}) - self.assertTrue(value == 11) - def test_adapted_boolean_deserialization(self): - """Test deserializing boolean field.""" - field = fields.AdaptedBoolean('[x]') +class DumpTestCase(TestCase): - value = field.deserialize({'x': 1}) - self.assertTrue(value is True) + def test_deserialize_value(self): + field = fields.Dump('value') + value = field.deserialize({}) + self.assertTrue(value == 'value') - value = field.deserialize({'x': 'hello'}) - self.assertTrue(value is True) - value = field.deserialize({'x': True}) - self.assertTrue(value is True) +class DecimalTestCase(TestCase): - value = field.deserialize({'x': 0}) - self.assertTrue(value is False) + def test_deserialize_value(self): + field = fields.Decimal('[key]') + value = field.deserialize({'key': '10.50'}) + self.assertTrue(value == Decimal('10.50')) - value = field.deserialize({'x': ''}) - self.assertTrue(value is False) + def test_deserialize_invalid_value(self): + try: + fields.Decimal('[key]').deserialize({'key': 'AB'}) + self.assertTrue(False) + except InvalidOperation: + self.assertTrue(True) - def test_adapted_date_deserialization(self): - """Test deserializing date field.""" - field = fields.AdaptedDate('[x]') - value = field.deserialize({'x': '2015-01-01'}) - self.assertTrue(value.year == 2015) - self.assertTrue(value.month == 1) - self.assertTrue(value.day == 1) - field = fields.AdaptedDate('[x]', date_format='%m/%d/%Y') - value = field.deserialize({'x': '01/01/2015'}) - self.assertTrue(value.year == 2015) - self.assertTrue(value.month == 1) - self.assertTrue(value.day == 1) +class IntegerTestCase(TestCase): - def test_adapted_decimal_deserialization(self): - """Test deserializing decimal field.""" - from decimal import Decimal + def test_deserialize_value(self): + field = fields.Integer('[key]') + value = field.deserialize({'key': '10'}) + self.assertTrue(value == 10) - field = fields.AdaptedDecimal('[x]') - value = field.deserialize({'x': '12.50'}) + def test_deserialize_invalid_value(self): + try: + fields.Integer('[key]').deserialize({'key': 'AB'}) + self.assertTrue(False) + except ValueError: + self.assertTrue(True) - self.assertTrue(value == 12.50) - self.assertTrue(isinstance(value, Decimal)) - def test_adapted_integer_deserialization(self): - """Test deserializing integer field.""" - field = fields.AdaptedInteger('[x]') - value = field.deserialize({'x': '1'}) - self.assertTrue(value == 1) +class FunctionTestCase(TestCase): def test_adapted_function_deserialization(self): """Test deserializing function field.""" - field = fields.AdaptedFunction(lambda x: 'value', path='[x]') + field = fields.Function(lambda x: 'value', path='[x]') value = field.deserialize({'x': 'anything'}) self.assertTrue(value == 'value') - def test_adapted_list_deserialization(self): + +class ListTestCase(TestCase): + + def test_deserialize_value(self): """Test deserializing list field.""" - field = fields.AdaptedList('[x]') + field = fields.List('[x]') value = field.deserialize({'x': 1}) self.assertTrue(value == [1]) @@ -121,19 +105,32 @@ def test_adapted_list_deserialization(self): value = field.deserialize({'x': ['hi']}) self.assertTrue(value == ['hi']) - def test_adapted_nested_deserialization(self): + def test_deserialize_value_from_position(self): + """Test deserializing a list item.""" + field = fields.Field('[1]') + value = field.deserialize([1, 2, 3]) + self.assertTrue(value == 2) + + +class NestedTestCase(TestCase): + + def test_deserialize_value(self): """Test deserializing nested field.""" - class Test(models.AdaptedModel): - number = fields.AdaptedInteger('[y]') + class Test(models.Model): + number = fields.Integer('[y]') - field = fields.AdaptedNested(Test, path='[x]') + field = fields.Nested(Test, path='[x]') value = field.deserialize({'x': {'y': 1}}) - self.assertTrue(isinstance(value, Test)) - self.assertTrue(value.number == 1) + self.assertTrue(isinstance(value, dict)) + self.assertIn('number', value) + self.assertTrue(value['number'] == 1) + + +class StringTestCase(TestCase): - def test_adapted_string_deserialization(self): + def test_deserialize_value(self): """Test deserializing string field.""" - field = fields.AdaptedString('[x]') + field = fields.String('[x]') value = field.deserialize({'x': 1}) self.assertTrue(value == '1') diff --git a/tests/test_models.py b/tests/test_models.py index d5e2c81..c6991be 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,34 +4,45 @@ from rest_orm import fields, models -class TestModel(models.AdaptedModel): - first = fields.AdaptedString('[first]') +class TestModel(models.Model): + first = fields.String('[first]') + x = fields.List('[x]', missing=[]) def make_request(self): return '{"first": "First Name"}' - def post_load(self): - self.full_name = '{} Last Name'.format(self.first) + def post_load(self, data): + data['full_name'] = '{} Last Name'.format(data['first']) + data['x'].append(1) + return data class ModelTestCase(TestCase): def test_model_connect(self): """Test loading the response from the connect method.""" - model = TestModel().connect() - self.assertTrue(model.first == 'First Name') + result = TestModel().connect() + self.assertTrue(result['first'] == 'First Name') def test_model_loads(self): """Test loading the json from the loads method.""" - model = TestModel().loads('{"first": "First Name"}') - self.assertTrue(model.first == 'First Name') + result = TestModel().loads('{"first": "First Name"}') + self.assertTrue(result['first'] == 'First Name') def test_model_load(self): """Test loading the python object from the load method.""" - model = TestModel().load({"first": "First Name"}) - self.assertTrue(model.first == 'First Name') + result = TestModel().load({"first": "First Name"}) + self.assertTrue(result['first'] == 'First Name') 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') + result = TestModel().load({"first": "First Name"}) + self.assertTrue(result['full_name'] == 'First Name Last Name') + + def test_model_missing_by_value(self): + """Ensure that the field instance is not mutated by post_load.""" + result = TestModel().connect() + self.assertTrue(result['x'] == [1]) + + result = TestModel().connect() + self.assertTrue(result['x'] == [1])