diff --git a/README.rst b/README.rst index 0c4233b..6b828d0 100644 --- a/README.rst +++ b/README.rst @@ -76,12 +76,38 @@ Features - Datetime fields can use any datetime format using parser and formatter functions. - No database dependent. - Auto documentation using https://github.com/alfred82santa/dirty-models-sphinx +- Json encoder. - Opensource (BSD License) --------- Changelog --------- +Version 0.7.0 +------------- + +- Timedelta field +- Generic formatters +- Json encoder + +.. code-block:: python + + import json + from datetime import datetime + from dirty_models import BaseModel, DatetimeField + from dirty_models.utils import JSONEncoder + + + class ExampleModel(BaseModel): + field_datetime = DatetimeField(parse_format="%Y-%m-%dT%H:%M:%S") + + model = ExampleModel(field_datetime=datetime.now()) + + assert json.dumps(model, cls=JSONEncoder) == '{"field_datetime": "2016-05-30T22:22:22"}' + +- Auto camelCase fields metaclass + + Version 0.6.3 ------------- diff --git a/dirty_models/fields.py b/dirty_models/fields.py index 5223c11..6261f3b 100644 --- a/dirty_models/fields.py +++ b/dirty_models/fields.py @@ -2,14 +2,14 @@ Fields to be used with dirty models. """ -from datetime import datetime, date, time +from datetime import datetime, date, time, timedelta from dateutil.parser import parse as dateutil_parse from .model_types import ListModel from collections import Mapping __all__ = ['IntegerField', 'FloatField', 'BooleanField', 'StringField', 'StringIdField', - 'TimeField', 'DateField', 'DateTimeField', 'ModelField', 'ArrayField', + 'TimeField', 'DateField', 'DateTimeField', 'TimedeltaField', 'ModelField', 'ArrayField', 'HashMapField', 'BlobField', 'MultiTypeField'] @@ -130,9 +130,9 @@ def check_value(self, value): return isinstance(value, float) def can_use_value(self, value): - return isinstance(value, int) \ - or (isinstance(value, str) - and value.replace('.', '', 1).isnumeric()) + return isinstance(value, int) or \ + (isinstance(value, str) and + value.replace('.', '', 1).isnumeric()) class BooleanField(BaseField): @@ -376,6 +376,20 @@ def can_use_value(self, value): return isinstance(value, (int, str, date, dict, list)) +class TimedeltaField(BaseField): + """It allows to use a timedelta as value in a field.""" + + def convert_value(self, value): + if isinstance(value, (int, float)): + return timedelta(seconds=value) + + def check_value(self, value): + return type(value) is timedelta + + def can_use_value(self, value): + return isinstance(value, (int, float)) + + class ModelField(BaseField): """ @@ -602,6 +616,16 @@ def can_use_value(self, value): return True return False + def get_field_type_by_value(self, value): + for ft in self._field_types: + if ft.check_value(value): + return ft + for ft in self._field_types: + if ft.can_use_value(value): + return ft + + raise TypeError("Value `{0}` can not be used on field `{1}`".format(value, self.name)) + @property def field_types(self): return self._field_types.copy() diff --git a/dirty_models/model_types.py b/dirty_models/model_types.py index 3f20017..cd4ca72 100644 --- a/dirty_models/model_types.py +++ b/dirty_models/model_types.py @@ -2,7 +2,7 @@ Internal types for dirty models """ import itertools -from dirty_models.base import BaseData, InnerFieldTypeMixin +from .base import BaseData, InnerFieldTypeMixin from functools import wraps diff --git a/dirty_models/models.py b/dirty_models/models.py index fab3300..351a801 100644 --- a/dirty_models/models.py +++ b/dirty_models/models.py @@ -8,10 +8,10 @@ from collections import Mapping from copy import deepcopy -from dirty_models.base import BaseData, InnerFieldTypeMixin -from dirty_models.fields import IntegerField, FloatField, BooleanField, StringField, DateTimeField -from dirty_models.model_types import ListModel -from .fields import BaseField, ModelField, ArrayField +from .base import BaseData, InnerFieldTypeMixin +from .fields import IntegerField, FloatField, BooleanField, StringField, DateTimeField, \ + BaseField, ModelField, ArrayField +from .model_types import ListModel __all__ = ['BaseModel', 'DynamicModel', 'FastDynamicModel', 'HashMapModel'] @@ -84,6 +84,20 @@ def prepare_field(cls, field): pass +class CamelCaseMeta(DirtyModelMeta): + + """ + Metaclass for dirty_models. Sets camel case version of field's name as default field name. + """ + + def process_base_field(self, field, key): + from .utils import underscore_to_camel + + if not field.name: + field.name = underscore_to_camel(key) + super(CamelCaseMeta, self).process_base_field(field, key) + + def recover_model_from_data(model_class, original_data, modified_data, deleted_data): """ Function to reconstruct a model from DirtyModel basic information: original data, the modified and deleted diff --git a/dirty_models/utils.py b/dirty_models/utils.py new file mode 100644 index 0000000..3380fab --- /dev/null +++ b/dirty_models/utils.py @@ -0,0 +1,99 @@ +import re +from json.encoder import JSONEncoder as BaseJSONEncoder +from datetime import date, datetime, time, timedelta +from .fields import MultiTypeField, DateTimeBaseField +from .model_types import ListModel +from .models import BaseModel, HashMapModel + + +def underscore_to_camel(string): + """ + Converts underscored string to camel case. + """ + return re.sub('_([a-z])', lambda x: x.group(1).upper(), string) + + +class BaseFormatterIter: + pass + + +class BaseFieldtypeFormatterIter(BaseFormatterIter): + + def __init__(self, obj, field, parent_formatter): + self.obj = obj + self.field = field + self.parent_formatter = parent_formatter + + +class ListFormatterIter(BaseFieldtypeFormatterIter): + + def __iter__(self): + for item in self.obj: + yield self.parent_formatter.format_field(self.field, item) + + +class HashMapFormatterIter(BaseFieldtypeFormatterIter): + + def __iter__(self): + for fieldname in self.obj.get_fields(): + value = self.obj.get_field_value(fieldname) + yield fieldname, self.parent_formatter.format_field(self.field, value) + + +class BaseModelFormatterIter(BaseFormatterIter): + """ + Base formatter iterator for Dirty Models. + """ + + def __init__(self, model): + self.model = model + + def __iter__(self): + fields = self.model.get_fields() + for fieldname in fields: + field = self.model.get_field_obj(fieldname) + yield field.name, self.format_field(field, + self.model.get_field_value(fieldname)) + + def format_field(self, field, value): + if isinstance(field, MultiTypeField): + return self.format_field(field.get_field_type_by_value(value), value) + elif isinstance(value, HashMapModel): + return HashMapFormatterIter(obj=value, field=value.get_field_type(), parent_formatter=self) + elif isinstance(value, BaseModel): + return self.__class__(value) + elif isinstance(value, ListModel): + return ListFormatterIter(obj=value, field=value.get_field_type(), parent_formatter=self) + + return value + + +class ModelFormatterIter(BaseModelFormatterIter): + + """ + Iterate over model fields formatting them. + """ + + def format_field(self, field, value): + if isinstance(value, (date, datetime, time)) and \ + isinstance(field, DateTimeBaseField): + return field.get_formatted_value(value) + elif isinstance(value, timedelta): + return value.total_seconds() + + return super(ModelFormatterIter, self).format_field(field, value) + + +class JSONEncoder(BaseJSONEncoder): + + """ + Json encoder for Dirty Models + """ + + def default(self, obj): + if isinstance(obj, BaseModel): + return {k: v for k, v in ModelFormatterIter(obj)} + elif isinstance(obj, (HashMapFormatterIter, ModelFormatterIter)): + return {k: v for k, v in obj} + elif isinstance(obj, ListFormatterIter): + return list(obj) diff --git a/docs/source/conf.py b/docs/source/conf.py index a23f21c..1b36daa 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -65,9 +65,9 @@ # built documents. # # The short X.Y version. -version = '0.6.3' +version = '0.7.0' # The full version, including alpha/beta/rc tags. -release = '0.6.3' +release = '0.7.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/source/dirty_models.rst b/docs/source/dirty_models.rst index 9fb7b99..7b7a763 100644 --- a/docs/source/dirty_models.rst +++ b/docs/source/dirty_models.rst @@ -8,4 +8,5 @@ Dirty Models API models fields inner_models - base \ No newline at end of file + base + utils \ No newline at end of file diff --git a/docs/source/utils.rst b/docs/source/utils.rst new file mode 100644 index 0000000..7a3318c --- /dev/null +++ b/docs/source/utils.rst @@ -0,0 +1,7 @@ +Utilities +========= + +.. automodule:: dirty_models.utils + :members: + :show-inheritance: + :no-undoc-members: diff --git a/setup.py b/setup.py index d2167d2..fbe2415 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ name='dirty-models', url='https://github.com/alfred82santa/dirty-models', author='alfred82santa', - version='0.6.3', + version='0.7.0', author_email='alfred82santa@gmail.com', classifiers=[ 'Intended Audience :: Developers', diff --git a/tests/dirty_models/tests_fields.py b/tests/dirty_models/tests_fields.py index 4101ffb..ce69477 100644 --- a/tests/dirty_models/tests_fields.py +++ b/tests/dirty_models/tests_fields.py @@ -1,11 +1,11 @@ from unittest import TestCase from dirty_models.fields import (IntegerField, StringField, BooleanField, FloatField, ModelField, TimeField, DateField, - DateTimeField, ArrayField, StringIdField, HashMapField, MultiTypeField) + DateTimeField, ArrayField, StringIdField, HashMapField, MultiTypeField, TimedeltaField) from dirty_models.models import BaseModel, HashMapModel from dirty_models.model_types import ListModel -from datetime import time, date, datetime, timezone +from datetime import time, date, datetime, timezone, timedelta import iso8601 @@ -1448,6 +1448,18 @@ def test_update_integer_field(self): self.model.multi_field = 3 self.assertEqual(self.model.multi_field, 3) + def test_get_field_type_by_value(self): + multi_field = MultiTypeField(field_types=[IntegerField(), (ArrayField, {"field_type": StringField()})]) + self.assertIsInstance(multi_field.get_field_type_by_value(['foo', 'bar']), + ArrayField) + self.assertIsInstance(multi_field.get_field_type_by_value(3), + IntegerField) + + def test_get_field_type_by_value_fail(self): + multi_field = MultiTypeField(field_types=[IntegerField(), (ArrayField, {"field_type": StringField()})]) + with self.assertRaises(TypeError): + multi_field.get_field_type_by_value({}) + class TestAutoreferenceModel(TestCase): @@ -1467,3 +1479,30 @@ def test_model_reference(self): self.assertIsInstance(self.model.multi_field[0], self.model.__class__) self.assertIsInstance(self.model.multi_field[1], self.model.__class__) self.assertIsInstance(self.model.array_of_array[0][0], self.model.__class__) + + +class TestTimedeltaField(TestCase): + + def setUp(self): + self.field = TimedeltaField() + + def test_check_value_success(self): + self.assertTrue(self.field.check_value(timedelta(seconds=0))) + + def test_check_value_fail(self): + self.assertFalse(self.field.check_value(12)) + + def test_can_use_value_int(self): + self.assertTrue(self.field.can_use_value(12)) + + def test_can_use_value_float(self): + self.assertTrue(self.field.can_use_value(12.11)) + + def test_can_use_value_fail(self): + self.assertFalse(self.field.can_use_value('test')) + + def test_convert_value_int(self): + self.assertTrue(self.field.convert_value(12), timedelta(seconds=12)) + + def test_convert_value_float(self): + self.assertTrue(self.field.convert_value(12.11), timedelta(seconds=12, milliseconds=110)) diff --git a/tests/dirty_models/tests_models.py b/tests/dirty_models/tests_models.py index 66f91a8..ca669a2 100644 --- a/tests/dirty_models/tests_models.py +++ b/tests/dirty_models/tests_models.py @@ -6,7 +6,7 @@ from dirty_models.fields import (BaseField, IntegerField, FloatField, StringField, DateTimeField, ModelField, ArrayField, BooleanField, DateField, TimeField, HashMapField) -from dirty_models.models import BaseModel, DynamicModel, HashMapModel, FastDynamicModel +from dirty_models.models import BaseModel, DynamicModel, HashMapModel, FastDynamicModel, CamelCaseMeta INITIAL_DATA = { 'testField1': 'testValue1', @@ -1386,3 +1386,32 @@ def test_modify_model(self): 'field_integer': 4, 'field_string': 'test', 'field_time': time(13, 56, 59)}) + + +class CamelCaseMetaclassTest(TestCase): + + def test_camelcase_fields(self): + + class TestModel(BaseModel, metaclass=CamelCaseMeta): + + test_field_1 = StringField() + test_field2 = StringField() + testField_3 = StringField() + testField4 = StringField(name='test_field_4') + + model = TestModel(data={'testField_1': 'foo', + 'testField2': 'bar', + 'testField_3': 'tor', + 'test_field_4': 'pir'}) + + self.assertEqual(model.test_field_1, 'foo') + self.assertEqual(model.testField_1, 'foo') + self.assertEqual(model.test_field2, 'bar') + self.assertEqual(model.testField2, 'bar') + self.assertEqual(model.testField_3, 'tor') + self.assertEqual(model.testField4, 'pir') + self.assertEqual(model.test_field_4, 'pir') + self.assertEqual(model.export_data(), {'testField_1': 'foo', + 'testField2': 'bar', + 'testField_3': 'tor', + 'test_field_4': 'pir'}) diff --git a/tests/dirty_models/tests_utils.py b/tests/dirty_models/tests_utils.py new file mode 100644 index 0000000..298f80d --- /dev/null +++ b/tests/dirty_models/tests_utils.py @@ -0,0 +1,121 @@ +from datetime import datetime, date, timedelta +from json import dumps, loads +from unittest.case import TestCase + +from dirty_models.fields import StringIdField, IntegerField, DateTimeField, ArrayField, MultiTypeField, ModelField, \ + HashMapField, DateField, TimedeltaField +from dirty_models.models import BaseModel +from dirty_models.utils import underscore_to_camel, ModelFormatterIter, ListFormatterIter, HashMapFormatterIter, \ + JSONEncoder + + +class UnderscoreToCamelTests(TestCase): + + def test_no_underscore(self): + self.assertEqual(underscore_to_camel('foobar'), 'foobar') + + def test_underscore(self): + self.assertEqual(underscore_to_camel('foo_bar'), 'fooBar') + + def test_underscore_multi(self): + self.assertEqual(underscore_to_camel('foo_bar_tor_pir'), 'fooBarTorPir') + + def test_underscore_number(self): + self.assertEqual(underscore_to_camel('foo_bar_1'), 'fooBar_1') + + def test_underscore_multi_number(self): + self.assertEqual(underscore_to_camel('foo_bar_tor_pir_1'), 'fooBarTorPir_1') + + +class TestModel(BaseModel): + + test_string_field_1 = StringIdField(name='other_field') + test_int_field_1 = IntegerField() + test_datetime = DateTimeField(parse_format="%Y-%m-%dT%H:%M:%S") + test_array_datetime = ArrayField(field_type=DateTimeField(parse_format="%Y-%m-%dT%H:%M:%S")) + test_array_multitype = ArrayField(field_type=MultiTypeField(field_types=[IntegerField(), + DateTimeField( + parse_format="%Y-%m-%dT%H:%M:%S" + )])) + test_model_field_1 = ArrayField(field_type=ArrayField(field_type=ModelField())) + test_hash_map = HashMapField(field_type=DateField(parse_format="%Y-%m-%d date")) + test_timedelta = TimedeltaField() + + +class ModelFormatterIterTests(TestCase): + + def test_model_formatter(self): + + model = TestModel(data={'test_string_field_1': 'foo', + 'test_int_field_1': 4, + 'test_datetime': datetime(year=2016, month=5, day=30, + hour=22, minute=22, second=22), + 'test_array_datetime': [datetime(year=2015, month=5, day=30, + hour=22, minute=22, second=22), + datetime(year=2015, month=6, day=30, + hour=22, minute=22, second=22)], + 'test_array_multitype': [datetime(year=2015, month=5, day=30, + hour=22, minute=22, second=22), + 4, 5], + 'test_model_field_1': [[{'test_datetime': datetime(year=2015, month=7, day=30, + hour=22, minute=22, second=22)}]], + 'test_hash_map': {'foo': date(year=2015, month=7, day=30)}, + 'test_timedelta': timedelta(seconds=32.1122)}) + + formatter = ModelFormatterIter(model) + data = {k: v for k, v in formatter} + self.assertEqual(data['other_field'], 'foo') + self.assertEqual(data['test_int_field_1'], 4) + self.assertEqual(data['test_datetime'], '2016-05-30T22:22:22') + self.assertIsInstance(data['test_array_datetime'], ListFormatterIter) + self.assertEqual(list(data['test_array_datetime']), ['2015-05-30T22:22:22', '2015-06-30T22:22:22']) + self.assertIsInstance(data['test_array_multitype'], ListFormatterIter) + self.assertEqual(list(data['test_array_multitype']), ['2015-05-30T22:22:22', 4, 5]) + self.assertIsInstance(data['test_model_field_1'], ListFormatterIter) + self.assertIsInstance(list(data['test_model_field_1'])[0], ListFormatterIter) + self.assertEqual({k: v for k, v in list(list(data['test_model_field_1'])[0])[0]}, + {'test_datetime': '2015-07-30T22:22:22'}) + self.assertIsInstance(data['test_hash_map'], HashMapFormatterIter) + self.assertEqual({k: v for k, v in data['test_hash_map']}, {'foo': '2015-07-30 date'}) + self.assertEqual(data['test_timedelta'], 32.1122) + + +class JSONEncoderTests(TestCase): + + def test_model_json(self): + + model = TestModel(data={'test_string_field_1': 'foo', + 'test_int_field_1': 4, + 'test_datetime': datetime(year=2016, month=5, day=30, + hour=22, minute=22, second=22), + 'test_array_datetime': [datetime(year=2015, month=5, day=30, + hour=22, minute=22, second=22), + datetime(year=2015, month=6, day=30, + hour=22, minute=22, second=22)], + 'test_array_multitype': [datetime(year=2015, month=5, day=30, + hour=22, minute=22, second=22), + 4, 5], + 'test_model_field_1': [[{'test_datetime': datetime(year=2015, month=7, day=30, + hour=22, minute=22, second=22)}]], + 'test_hash_map': {'foo': date(year=2015, month=7, day=30)}, + 'test_timedelta': timedelta(seconds=32.1122)}) + + json_str = dumps(model, cls=JSONEncoder) + + data = {'other_field': 'foo', + 'test_int_field_1': 4, + 'test_datetime': '2016-05-30T22:22:22', + 'test_array_datetime': ['2015-05-30T22:22:22', + '2015-06-30T22:22:22'], + 'test_array_multitype': ['2015-05-30T22:22:22', 4, 5], + 'test_model_field_1': [[{'test_datetime': '2015-07-30T22:22:22'}]], + 'test_hash_map': {'foo': '2015-07-30 date'}, + 'test_timedelta': 32.1122} + + self.assertEqual(loads(json_str), data) + + def test_general_use_json(self): + + data = {'foo': 3, 'bar': 'str'} + json_str = dumps(data, cls=JSONEncoder) + self.assertEqual(loads(json_str), data)