diff --git a/.travis.yml b/.travis.yml index 4d9009e..3bc0f80 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python python: - - "2.6" - "2.7" install: "pip install . --use-mirrors" script: "python setup.py test" diff --git a/jsonobject/api.py b/jsonobject/api.py index b9d0f49..ac9af3e 100644 --- a/jsonobject/api.py +++ b/jsonobject/api.py @@ -1,13 +1,50 @@ +from __future__ import absolute_import from .base import JsonObjectBase, _LimitedDictInterfaceMixin -from .convert import STRING_CONVERSIONS +import decimal +import datetime -class JsonObject(JsonObjectBase, _LimitedDictInterfaceMixin): +from . import properties +import re + + +re_date = re.compile(r'^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])$') +re_time = re.compile( + r'^([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3,6})?$') +re_datetime = re.compile( + r'^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])' + r'(\D?([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3,6})?' + r'([zZ]|([\+-])([01]\d|2[0-3])\D?([0-5]\d)?)?)?$' +) +re_decimal = re.compile('^(\d+)\.(\d+)$') - _string_conversions = STRING_CONVERSIONS +class JsonObject(JsonObjectBase, _LimitedDictInterfaceMixin): def __getstate__(self): return self.to_json() def __setstate__(self, dct): self.__init__(dct) + + class Meta(object): + properties = { + decimal.Decimal: properties.DecimalProperty, + datetime.datetime: properties.DateTimeProperty, + datetime.date: properties.DateProperty, + datetime.time: properties.TimeProperty, + str: properties.StringProperty, + unicode: properties.StringProperty, + bool: properties.BooleanProperty, + int: properties.IntegerProperty, + long: properties.IntegerProperty, + float: properties.FloatProperty, + list: properties.ListProperty, + dict: properties.DictProperty, + set: properties.SetProperty, + } + string_conversions = ( + (re_date, datetime.date), + (re_time, datetime.time), + (re_datetime, datetime.datetime), + (re_decimal, decimal.Decimal), + ) diff --git a/jsonobject/base.py b/jsonobject/base.py index 813b56e..a133492 100644 --- a/jsonobject/base.py +++ b/jsonobject/base.py @@ -1,4 +1,5 @@ from __future__ import absolute_import +from collections import namedtuple, OrderedDict import copy import inspect from .exceptions import ( @@ -11,10 +12,11 @@ class JsonProperty(object): default = None + type_config = None def __init__(self, default=Ellipsis, name=None, choices=None, required=False, exclude_if_none=False, validators=None, - verbose_name=None): + verbose_name=None, type_config=None): validators = validators or () self.name = name if default is Ellipsis: @@ -40,9 +42,12 @@ def _validator(value): else: self.custom_validator = validators self.verbose_name = verbose_name + if type_config: + self.type_config = type_config - def init_property(self, default_name): + def init_property(self, default_name, type_config): self.name = self.name or default_name + self.type_config = self.type_config or type_config def wrap(self, obj): raise NotImplementedError() @@ -119,46 +124,61 @@ class JsonContainerProperty(JsonProperty): container_class = None def __init__(self, item_type=None, **kwargs): - if inspect.isfunction(item_type): - self._item_type_deferred = item_type - else: - self.set_item_type(item_type) + self._item_type_deferred = item_type super(JsonContainerProperty, self).__init__(**kwargs) + def init_property(self, **kwargs): + super(JsonContainerProperty, self).init_property(**kwargs) + if not inspect.isfunction(self._item_type_deferred): + # trigger validation + self.item_type + def set_item_type(self, item_type): - from .convert import ALLOWED_PROPERTY_TYPES if hasattr(item_type, '_type'): item_type = item_type._type if isinstance(item_type, tuple): # this is for the case where item_type = (int, long) item_type = item_type[0] - self._item_type = item_type - if item_type and item_type not in tuple(ALLOWED_PROPERTY_TYPES) \ - and not issubclass(item_type, JsonObjectBase): + allowed_types = set(self.type_config.properties.keys()) + if isinstance(item_type, JsonObjectMeta) \ + or not item_type or item_type in allowed_types: + self._item_type = item_type + else: raise ValueError("item_type {0!r} not in {1!r}".format( item_type, - ALLOWED_PROPERTY_TYPES, + allowed_types, )) @property def item_type(self): if hasattr(self, '_item_type_deferred'): - self.set_item_type(self._item_type_deferred()) + if inspect.isfunction(self._item_type_deferred): + self.set_item_type(self._item_type_deferred()) + else: + self.set_item_type(self._item_type_deferred) del self._item_type_deferred return self._item_type def empty(self, value): return not value - def wrap(self, obj, string_conversions=None): - from .properties import type_to_property - wrapper = type_to_property(self.item_type) if self.item_type else None - assert ( - getattr(self.container_class, '_string_conversions', None) is not None - or (string_conversions is not None) - ), self.container_class - return self.container_class( - obj, wrapper=wrapper, string_conversions=string_conversions) + def wrap(self, obj): + wrapper = self.type_to_property(self.item_type) if self.item_type else None + return self.container_class(obj, wrapper=wrapper, + type_config=self.type_config) + + def type_to_property(self, item_type): + map_types_properties = self.type_config.properties + from .properties import ObjectProperty + if issubclass(item_type, JsonObjectBase): + return ObjectProperty(item_type, type_config=self.type_config) + elif item_type in map_types_properties: + return map_types_properties[item_type](type_config=self.type_config) + else: + for key, value in map_types_properties.items(): + if issubclass(item_type, key): + return value(type_config=self.type_config) + raise TypeError('Type {0} not recognized'.format(item_type)) def unwrap(self, obj): if not isinstance(obj, self._type): @@ -169,7 +189,7 @@ def unwrap(self, obj): if isinstance(obj, self.container_class): return obj, obj._obj else: - wrapped = self.wrap(self._type(), string_conversions=()) + wrapped = self.wrap(self._type()) self._update(wrapped, obj) return self.unwrap(wrapped) @@ -179,35 +199,65 @@ def _update(self, container, extension): class DefaultProperty(JsonProperty): - def __init__(self, **kwargs): - self.string_conversions = kwargs.pop('string_conversions', None) - super(DefaultProperty, self).__init__(**kwargs) - def wrap(self, obj): - assert self.string_conversions is not None - from . import convert - value = convert.value_to_python( - obj, string_conversions=self.string_conversions) - property_ = convert.value_to_property(value) + assert self.type_config.string_conversions is not None + value = self.value_to_python(obj) + property_ = self.value_to_property(value) if property_: - try: - return property_.wrap( - obj, - **({'string_conversions': self.string_conversions} - if isinstance(property_, JsonContainerProperty) else {}) - ) - except TypeError: - return property_.wrap(obj) + return property_.wrap(obj) def unwrap(self, obj): - from . import convert - property_ = convert.value_to_property(obj) + property_ = self.value_to_property(obj) if property_: return property_.unwrap(obj) else: return obj, None + def value_to_property(self, value): + map_types_properties = self.type_config.properties + if value is None: + return None + elif type(value) in map_types_properties: + return map_types_properties[type(value)]( + type_config=self.type_config) + else: + for value_type, prop_class in map_types_properties.items(): + if isinstance(value, value_type): + return prop_class(type_config=self.type_config) + else: + raise BadValueError( + 'value {0!r} not in allowed types: {1!r}'.format( + value, map_types_properties.keys()) + ) + + def value_to_python(self, value): + """ + convert encoded string values to the proper python type + + ex: + >>> DefaultProperty().value_to_python('2013-10-09T10:05:51Z') + datetime.datetime(2013, 10, 9, 10, 5, 51) + + other values will be passed through unmodified + Note: containers' items are NOT recursively converted + + """ + if isinstance(value, basestring): + convert = None + for pattern, _convert in self.type_config.string_conversions: + if pattern.match(value): + convert = _convert + break + + if convert is not None: + try: + #sometimes regex fail so return value + value = convert(value) + except Exception: + pass + return value + class AssertTypeProperty(JsonProperty): _type = None @@ -276,16 +326,16 @@ def check_type(obj, item_type, message): class JsonArray(list): - def __init__(self, _obj=None, wrapper=None, string_conversions=None): + def __init__(self, _obj=None, wrapper=None, type_config=None): super(JsonArray, self).__init__() self._obj = check_type(_obj, list, 'JsonArray must wrap a list or None') - assert string_conversions is not None - self._string_conversions = string_conversions + assert type_config is not None + self._type_config = type_config self._wrapper = ( wrapper or - DefaultProperty(string_conversions=self._string_conversions) + DefaultProperty(type_config=self._type_config) ) for item in self._obj: super(JsonArray, self).append(self._wrapper.wrap(item)) @@ -391,14 +441,14 @@ def clear(self): class JsonDict(SimpleDict): - def __init__(self, _obj=None, wrapper=None, string_conversions=None): + def __init__(self, _obj=None, wrapper=None, type_config=None): super(JsonDict, self).__init__() self._obj = check_type(_obj, dict, 'JsonDict must wrap a dict or None') - assert string_conversions is not None - self._string_conversions = string_conversions + assert type_config is not None + self._type_config = type_config self._wrapper = ( wrapper or - DefaultProperty(string_conversions=self._string_conversions) + DefaultProperty(type_config=self._type_config) ) for key, value in self._obj.items(): self[key] = self.__wrap(key, value) @@ -432,16 +482,16 @@ def __getitem__(self, key): class JsonSet(set): - def __init__(self, _obj=None, wrapper=None, string_conversions=None): + def __init__(self, _obj=None, wrapper=None, type_config=None): super(JsonSet, self).__init__() if isinstance(_obj, set): _obj = list(_obj) self._obj = check_type(_obj, list, 'JsonSet must wrap a list or None') - assert string_conversions is not None - self._string_conversions = string_conversions + assert type_config is not None + self._type_config = type_config self._wrapper = ( wrapper or - DefaultProperty(string_conversions=self._string_conversions) + DefaultProperty(type_config=self._type_config) ) for item in self._obj: super(JsonSet, self).add(self._wrapper.wrap(item)) @@ -530,16 +580,113 @@ def symmetric_difference_update(self, *args): self ^= set(wrapped_list) +JsonObjectClassSettings = namedtuple('JsonObjectClassSettings', ['type_config']) + +CLASS_SETTINGS_ATTR = '_$_class_settings' + + +def get_settings(cls): + return getattr(cls, CLASS_SETTINGS_ATTR, + JsonObjectClassSettings(type_config=TypeConfig())) + + +def set_settings(cls, settings): + setattr(cls, CLASS_SETTINGS_ATTR, settings) + + +class TypeConfig(object): + """ + This class allows the user to configure dynamic + type handlers and string conversions for their JsonObject. + + properties is a map from python types to JsonProperty subclasses + string_conversions is a list or tuple of (regex, python type)-tuples + + This class is used to store the configuration but is not part of the API. + To configure: + + class Foo(JsonObject): + # property definitions go here + # ... + + class Meta(object): + update_properties = { + datetime.datetime: MySpecialDateTimeProperty + } + + If you now do + + foo = Foo() + foo.timestamp = datetime.datetime(1988, 7, 7, 11, 8, 0) + + timestamp will be governed by a MySpecialDateTimeProperty + instead of the default. + + """ + def __init__(self, properties=None, string_conversions=None): + self._properties = properties if properties is not None else {} + + self._string_conversions = ( + OrderedDict(string_conversions) if string_conversions is not None + else OrderedDict() + ) + # cache this + self.string_conversions = self._get_string_conversions() + self.properties = self._properties + + def replace(self, properties=None, string_conversions=None): + return TypeConfig( + properties=(properties if properties is not None + else self._properties), + string_conversions=(string_conversions if string_conversions is not None + else self._string_conversions) + ) + + def updated(self, properties=None, string_conversions=None): + """ + update properties and string_conversions with the paramenters + keeping all non-mentioned items the same as before + returns a new TypeConfig with these changes + (does not modify original) + + """ + _properties = self._properties.copy() + _string_conversions = self.string_conversions[:] + if properties: + _properties.update(properties) + if string_conversions: + _string_conversions.extend(string_conversions) + return TypeConfig( + properties=_properties, + string_conversions=_string_conversions, + ) + + def _get_string_conversions(self): + result = [] + for pattern, conversion in self._string_conversions.items(): + conversion = ( + conversion if conversion not in self._properties + else self._properties[conversion](type_config=self).to_python + ) + result.append((pattern, conversion)) + return result + +META_ATTRS = ('properties', 'string_conversions', 'update_properties') + + class JsonObjectMeta(type): + + class Meta(object): + pass + def __new__(mcs, name, bases, dct): - # There's a pretty fundamental cyclic dependency between this metaclass - # and knowledge of all available property types (in properties module). - # The current solution is to monkey patch this metaclass - # with a reference to the properties module - try: - from . import convert as _c - except ImportError: - _c = None + cls = type.__new__(mcs, name, bases, dct) + + cls.__configure(**{key: value + for key, value in cls.Meta.__dict__.items() + if key in META_ATTRS}) + cls_settings = get_settings(cls) + properties = {} properties_by_name = {} for key, value in dct.items(): @@ -547,21 +694,19 @@ def __new__(mcs, name, bases, dct): properties[key] = value elif key.startswith('_'): continue - elif _c and type(value) in _c.MAP_TYPES_PROPERTIES: - property_ = _c.MAP_TYPES_PROPERTIES[type(value)](default=value) + elif type(value) in cls_settings.type_config.properties: + property_ = cls_settings.type_config.properties[type(value)](default=value) properties[key] = dct[key] = property_ - - cls = type.__new__(mcs, name, bases, dct) + setattr(cls, key, property_) for key, property_ in properties.items(): - property_.init_property(default_name=key) + property_.init_property(default_name=key, + type_config=cls_settings.type_config) assert property_.name is not None, property_ assert property_.name not in properties_by_name, \ 'You can only have one property named {0}'.format( property_.name) properties_by_name[property_.name] = property_ - if isinstance(property_, DefaultProperty): - property_.string_conversions = cls._string_conversions for base in bases: if getattr(base, '_properties_by_attr', None): @@ -574,6 +719,22 @@ def __new__(mcs, name, bases, dct): cls._properties_by_key = properties_by_name return cls + def __configure(cls, properties=None, string_conversions=None, + update_properties=None): + super_settings = get_settings(super(cls, cls)) + assert not properties or not update_properties, \ + "{} {}".format(properties, update_properties) + type_config = super_settings.type_config + if update_properties is not None: + type_config = type_config.updated(properties=update_properties) + elif properties is not None: + type_config = type_config.replace(properties=properties) + if string_conversions is not None: + type_config = type_config.replace( + string_conversions=string_conversions) + set_settings(cls, super_settings._replace(type_config=type_config)) + return cls + class _JsonObjectPrivateInstanceVariables(object): @@ -593,12 +754,9 @@ class JsonObjectBase(object): _string_conversions = () - def __init__(self, _obj=None, _string_conversions=None, **kwargs): + def __init__(self, _obj=None, **kwargs): setattr(self, '_$', _JsonObjectPrivateInstanceVariables()) - if _string_conversions is not None: - self._string_conversions = _string_conversions - self._obj = check_type(_obj, dict, 'JsonObject must wrap a dict or None') self._wrapped = {} @@ -669,7 +827,7 @@ def __get_property(self, key): try: return self._properties_by_key[key] except KeyError: - return DefaultProperty(string_conversions=self._string_conversions) + return DefaultProperty(type_config=get_settings(self).type_config) def __wrap(self, key, value): property_ = self.__get_property(key) @@ -680,14 +838,7 @@ def __wrap(self, key, value): if isinstance(property_, JsonArray): assert isinstance(property_, JsonContainerProperty) - try: - return property_.wrap( - value, - **({'string_conversions': self._string_conversions} - if isinstance(property_, JsonContainerProperty) else {}) - ) - except TypeError: - return property_.wrap(value) + return property_.wrap(value) def __unwrap(self, key, value): property_ = self.__get_property(key) diff --git a/jsonobject/convert.py b/jsonobject/convert.py deleted file mode 100644 index 28c54cf..0000000 --- a/jsonobject/convert.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -This file was excerpted directly from couchdbkit.schema.properties -and edited to fit the needs of jsonobject - -""" -from __future__ import absolute_import -import decimal -import datetime - -from . import properties -from .exceptions import BadValueError -import re - - -re_date = re.compile('^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])$') -re_time = re.compile('^([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3})?$') -re_datetime = re.compile( - r'^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])' - '(\D?([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3})?' - '([zZ]|([\+-])([01]\d|2[0-3])\D?([0-5]\d)?)?)?$' -) -re_decimal = re.compile('^(\d+)\.(\d+)$') - - -ALLOWED_PROPERTY_TYPES = set([ - basestring, - str, - unicode, - bool, - int, - long, - float, - datetime.datetime, - datetime.date, - datetime.time, - decimal.Decimal, - dict, - list, - set, - type(None) -]) - -MAP_TYPES_PROPERTIES = { - decimal.Decimal: properties.DecimalProperty, - datetime.datetime: properties.DateTimeProperty, - datetime.date: properties.DateProperty, - datetime.time: properties.TimeProperty, - str: properties.StringProperty, - unicode: properties.StringProperty, - basestring: properties.StringProperty, - bool: properties.BooleanProperty, - int: properties.IntegerProperty, - long: properties.IntegerProperty, - float: properties.FloatProperty, - list: properties.ListProperty, - dict: properties.DictProperty, - set: properties.SetProperty, -} - - -def value_to_property(value): - if value is None: - return None - elif type(value) in MAP_TYPES_PROPERTIES: - prop = MAP_TYPES_PROPERTIES[type(value)]() - return prop - else: - for value_type, prop_class in MAP_TYPES_PROPERTIES.items(): - if isinstance(value, value_type): - return prop_class() - else: - raise BadValueError( - 'value {0!r} not in allowed types: {1!r}'.format( - value, MAP_TYPES_PROPERTIES.keys()) - ) - - -STRING_CONVERSIONS = ( - (re_date, properties.DateProperty().to_python), - (re_time, properties.TimeProperty().to_python), - (re_datetime, properties.DateTimeProperty().to_python), - (re_decimal, properties.DecimalProperty().to_python), -) - - -def value_to_python(value, string_conversions=STRING_CONVERSIONS): - """ - convert encoded string values to the proper python type - - ex: - >>> value_to_python('2013-10-09T10:05:51Z') - datetime.datetime(2013, 10, 9, 10, 5, 51) - - other values will be passed through unmodified - Note: containers' items are NOT recursively converted - - """ - if isinstance(value, basestring): - convert = None - for pattern, _convert in string_conversions: - if pattern.match(value): - convert = _convert - break - - if convert is not None: - try: - #sometimes regex fail so return value - value = convert(value) - except Exception: - pass - return value diff --git a/jsonobject/properties.py b/jsonobject/properties.py index 604b7f3..3779f5d 100644 --- a/jsonobject/properties.py +++ b/jsonobject/properties.py @@ -10,14 +10,13 @@ JsonArray, JsonContainerProperty, JsonDict, - JsonObjectBase, JsonProperty, JsonSet, ) class StringProperty(AssertTypeProperty): - _type = basestring + _type = (unicode, str) def selective_coerce(self, obj): if isinstance(obj, str): @@ -157,16 +156,3 @@ class SetProperty(JsonContainerProperty): def _update(self, container, extension): container.update(extension) - - -def type_to_property(item_type, *args, **kwargs): - from .convert import MAP_TYPES_PROPERTIES - if issubclass(item_type, JsonObjectBase): - return ObjectProperty(item_type, *args, **kwargs) - elif item_type in MAP_TYPES_PROPERTIES: - return MAP_TYPES_PROPERTIES[item_type](*args, **kwargs) - else: - for key, value in MAP_TYPES_PROPERTIES.items(): - if issubclass(item_type, key): - return value(*args, **kwargs) - raise TypeError('Type {0} not recognized'.format(item_type)) diff --git a/setup.py b/setup.py index a724a49..7b39183 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='jsonobject', - version='0.5.0', + version='0.6.0b1', author='Danny Roberts', author_email='droberts@dimagi.com', description='A library for dealing with JSON as python objects', diff --git a/test/test_stringconversions.py b/test/test_stringconversions.py index 53a7898..fb05c82 100644 --- a/test/test_stringconversions.py +++ b/test/test_stringconversions.py @@ -1,7 +1,9 @@ from decimal import Decimal import datetime -from jsonobject import JsonObject, ObjectProperty +from jsonobject.exceptions import BadValueError +from jsonobject import JsonObject, ObjectProperty, DateTimeProperty import unittest2 +from jsonobject.base import get_settings class StringConversionsTest(unittest2.TestCase): @@ -9,6 +11,7 @@ class StringConversionsTest(unittest2.TestCase): EXAMPLES = { 'decimal': '1.2', 'date': '2014-02-04', + 'datetime': '2014-01-03T01:02:03Z', 'dict': { 'decimal': '1.4', }, @@ -21,6 +24,7 @@ class StringConversionsTest(unittest2.TestCase): 'decimal': Decimal('1.4'), }, 'list': [Decimal('1.0'), datetime.date(2000, 01, 01)], + 'datetime': datetime.datetime(2014, 1, 3, 1, 2, 3) } def test_default_conversions(self): @@ -32,7 +36,8 @@ class Foo(JsonObject): def test_no_conversions(self): class Foo(JsonObject): - _string_conversions = () + class Meta(object): + string_conversions = () foo = Foo.wrap(self.EXAMPLES) for key, value in self.EXAMPLES.items(): @@ -45,9 +50,11 @@ class Bar(JsonObject): pass class Foo(JsonObject): - _string_conversions = () bar = ObjectProperty(Bar) + class Meta(object): + string_conversions = () + foo = Foo.wrap({ # don't convert 'decimal': '1.0', @@ -60,7 +67,9 @@ class Foo(JsonObject): def test_nested_2(self): class Bar(JsonObject): - _string_conversions = () + + class Meta(object): + string_conversions = () class Foo(JsonObject): # default string conversions @@ -75,3 +84,33 @@ class Foo(JsonObject): self.assertNotEqual(foo.decimal, '1.0') self.assertEqual(foo.decimal, Decimal('1.0')) self.assertEqual(foo.bar.decimal, '2.4') + + def test_update_properties(self): + class Foo(JsonObject): + + class Meta(object): + update_properties = {datetime.datetime: ExactDateTimeProperty} + + self.assertEqual( + get_settings(Foo).type_config.properties[datetime.datetime], + ExactDateTimeProperty + ) + with self.assertRaisesRegexp(BadValueError, + 'is not a datetime-formatted string'): + Foo.wrap(self.EXAMPLES) + examples = self.EXAMPLES.copy() + examples['datetime'] = '2014-01-03T01:02:03.012345Z' + examples_converted = self.EXAMPLES_CONVERTED.copy() + examples_converted['datetime'] = datetime.datetime( + 2014, 1, 3, 1, 2, 3, 12345) + foo = Foo.wrap(examples) + for key, value in examples_converted.items(): + self.assertEqual(getattr(foo, key), value) + + +class ExactDateTimeProperty(DateTimeProperty): + def __init__(self, **kwargs): + if 'exact' in kwargs: + assert kwargs['exact'] is True + kwargs['exact'] = True + super(ExactDateTimeProperty, self).__init__(**kwargs) diff --git a/test/tests.py b/test/tests.py index dc5f721..fccef73 100644 --- a/test/tests.py +++ b/test/tests.py @@ -376,6 +376,13 @@ class Dummy(JsonObject): d.l2 = [longint] self.assertEqual(d.l2, [longint]) + def test_string_list_property(self): + + class Foo(JsonObject): + string_list = ListProperty(StringProperty) + + foo = Foo({'string_list': ['a', 'b', 'c']}) + self.assertEqual(foo.string_list, ['a', 'b', 'c']) class LazyValidationTest(unittest2.TestCase):