From 96e6651d8f88820325544194b2f2ffb0d78efa3c Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Fri, 5 Sep 2014 13:49:07 -0400 Subject: [PATCH 01/13] get rid of redundant ALLOWED_PROPERTY_TYPES --- jsonobject/base.py | 7 ++++--- jsonobject/convert.py | 18 ------------------ 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/jsonobject/base.py b/jsonobject/base.py index 813b56e..f250b2e 100644 --- a/jsonobject/base.py +++ b/jsonobject/base.py @@ -126,18 +126,19 @@ def __init__(self, item_type=None, **kwargs): super(JsonContainerProperty, self).__init__(**kwargs) def set_item_type(self, item_type): - from .convert import ALLOWED_PROPERTY_TYPES + from .convert import MAP_TYPES_PROPERTIES + allowed_types = set(MAP_TYPES_PROPERTIES.keys()) 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) \ + if item_type and item_type not in allowed_types \ and not issubclass(item_type, JsonObjectBase): raise ValueError("item_type {0!r} not in {1!r}".format( item_type, - ALLOWED_PROPERTY_TYPES, + allowed_types, )) @property diff --git a/jsonobject/convert.py b/jsonobject/convert.py index 28c54cf..36d85ff 100644 --- a/jsonobject/convert.py +++ b/jsonobject/convert.py @@ -22,24 +22,6 @@ 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, From ad5f1534aa706f94cb6ddb0e422e55f783109f82 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Fri, 5 Sep 2014 17:30:45 -0400 Subject: [PATCH 02/13] remove basestring from MAP_TYPES_PROPERTIES test that the reason it was added does not break --- jsonobject/convert.py | 1 - jsonobject/properties.py | 2 +- test/tests.py | 7 +++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/jsonobject/convert.py b/jsonobject/convert.py index 36d85ff..d43b48c 100644 --- a/jsonobject/convert.py +++ b/jsonobject/convert.py @@ -29,7 +29,6 @@ datetime.time: properties.TimeProperty, str: properties.StringProperty, unicode: properties.StringProperty, - basestring: properties.StringProperty, bool: properties.BooleanProperty, int: properties.IntegerProperty, long: properties.IntegerProperty, diff --git a/jsonobject/properties.py b/jsonobject/properties.py index 604b7f3..1b150fb 100644 --- a/jsonobject/properties.py +++ b/jsonobject/properties.py @@ -17,7 +17,7 @@ class StringProperty(AssertTypeProperty): - _type = basestring + _type = (unicode, str) def selective_coerce(self, obj): if isinstance(obj, str): 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): From d28c0d2ca255ffd5571e1cf7845a30fe41224d9a Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Mon, 8 Sep 2014 11:50:05 -0400 Subject: [PATCH 03/13] make the treatment of types completely configurable - reverts/replaces the recent "_string_conversions" addition to the api - adds a 'Meta' class to the JsonObject api, for configuring this kind of setting - move "convert" file's parts into other files --- jsonobject/api.py | 42 +++++- jsonobject/base.py | 257 +++++++++++++++++++++++---------- jsonobject/convert.py | 92 ------------ jsonobject/properties.py | 14 -- test/test_stringconversions.py | 11 +- 5 files changed, 227 insertions(+), 189 deletions(-) delete mode 100644 jsonobject/convert.py diff --git a/jsonobject/api.py b/jsonobject/api.py index b9d0f49..fb27e7e 100644 --- a/jsonobject/api.py +++ b/jsonobject/api.py @@ -1,13 +1,49 @@ +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('^(\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+)$') - _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 f250b2e..7209128 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,23 +124,26 @@ 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 MAP_TYPES_PROPERTIES - allowed_types = set(MAP_TYPES_PROPERTIES.keys()) 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 allowed_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_types, @@ -144,22 +152,33 @@ def set_item_type(self, item_type): @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): @@ -170,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) @@ -180,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 @@ -277,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)) @@ -392,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) @@ -433,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)) @@ -531,16 +580,69 @@ 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): + 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 _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 + + 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 not key.startswith('_')}) + cls_settings = get_settings(cls) + properties = {} properties_by_name = {} for key, value in dct.items(): @@ -548,21 +650,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): @@ -575,6 +675,19 @@ def __new__(mcs, name, bases, dct): cls._properties_by_key = properties_by_name return cls + def __configure(cls, properties=None, string_conversions=None): + super_settings = get_settings(super(cls, cls)) + set_settings( + cls, + super_settings._replace( + type_config=super_settings.type_config.replace( + properties=properties, + string_conversions=string_conversions, + ) + ) + ) + return cls + class _JsonObjectPrivateInstanceVariables(object): @@ -594,12 +707,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 = {} @@ -670,7 +780,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) @@ -681,14 +791,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 d43b48c..0000000 --- a/jsonobject/convert.py +++ /dev/null @@ -1,92 +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+)$') - - -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, - 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 1b150fb..3779f5d 100644 --- a/jsonobject/properties.py +++ b/jsonobject/properties.py @@ -10,7 +10,6 @@ JsonArray, JsonContainerProperty, JsonDict, - JsonObjectBase, JsonProperty, JsonSet, ) @@ -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/test/test_stringconversions.py b/test/test_stringconversions.py index 53a7898..74e1dbe 100644 --- a/test/test_stringconversions.py +++ b/test/test_stringconversions.py @@ -32,7 +32,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 +46,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 +63,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 From 55f2fc5167047ecc5a8b81994e4aaabcf651a568 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Mon, 8 Sep 2014 13:27:28 -0400 Subject: [PATCH 04/13] add and test update_properties Meta variable --- jsonobject/api.py | 4 ++-- jsonobject/base.py | 34 ++++++++++++++++++++++---------- test/test_stringconversions.py | 36 +++++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/jsonobject/api.py b/jsonobject/api.py index fb27e7e..2b72647 100644 --- a/jsonobject/api.py +++ b/jsonobject/api.py @@ -9,10 +9,10 @@ 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_time = re.compile('^([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])' - '(\D?([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3})?' + '(\D?([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3,6})?' '([zZ]|([\+-])([01]\d|2[0-3])\D?([0-5]\d)?)?)?$' ) re_decimal = re.compile('^(\d+)\.(\d+)$') diff --git a/jsonobject/base.py b/jsonobject/base.py index 7209128..1f07216 100644 --- a/jsonobject/base.py +++ b/jsonobject/base.py @@ -614,6 +614,18 @@ def replace(self, properties=None, string_conversions=None): else self._string_conversions) ) + def update(self, properties=None, string_conversions=None): + _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(): @@ -675,17 +687,19 @@ def __new__(mcs, name, bases, dct): cls._properties_by_key = properties_by_name return cls - def __configure(cls, properties=None, string_conversions=None): + def __configure(cls, properties=None, string_conversions=None, + update_properties=None): super_settings = get_settings(super(cls, cls)) - set_settings( - cls, - super_settings._replace( - type_config=super_settings.type_config.replace( - properties=properties, - string_conversions=string_conversions, - ) - ) - ) + assert len(filter(None, (properties, update_properties))) <= 1 + type_config = super_settings.type_config + if update_properties is not None: + type_config = type_config.update(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 diff --git a/test/test_stringconversions.py b/test/test_stringconversions.py index 74e1dbe..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): @@ -80,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) From e82deb313abc58964d27181d26fc2ad0ce4976f6 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Mon, 8 Sep 2014 14:13:04 -0400 Subject: [PATCH 05/13] remove support for python 2.6 --- .travis.yml | 1 - 1 file changed, 1 deletion(-) 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" From 88535ecf7ea9ca3f1eac4d2cba792d50c91de752 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Mon, 8 Sep 2014 14:35:43 -0400 Subject: [PATCH 06/13] allow unknown options in Meta this is to allow, for example, couchdbkit to highjack this as well --- jsonobject/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jsonobject/base.py b/jsonobject/base.py index 1f07216..7d56d82 100644 --- a/jsonobject/base.py +++ b/jsonobject/base.py @@ -636,6 +636,8 @@ def _get_string_conversions(self): result.append((pattern, conversion)) return result +META_ATTRS = ('properties', 'string_conversions', 'update_properties') + class JsonObjectMeta(type): @@ -652,7 +654,7 @@ def __new__(mcs, name, bases, dct): cls.__configure(**{key: value for key, value in cls.Meta.__dict__.items() - if not key.startswith('_')}) + if key in META_ATTRS}) cls_settings = get_settings(cls) properties = {} From 59a46c48bec67b9adf7883b15ef24646214e66ef Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Mon, 8 Sep 2014 16:42:06 -0400 Subject: [PATCH 07/13] bump version to 0.6.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a724a49..8ce909a 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='jsonobject', - version='0.5.0', + version='0.6.0', author='Danny Roberts', author_email='droberts@dimagi.com', description='A library for dealing with JSON as python objects', From efa292409f0289b9464ae5ba47be651cf6c8a351 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Tue, 9 Sep 2014 09:26:43 -0400 Subject: [PATCH 08/13] r-prefix all regexes --- jsonobject/api.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/jsonobject/api.py b/jsonobject/api.py index 2b72647..ac9af3e 100644 --- a/jsonobject/api.py +++ b/jsonobject/api.py @@ -8,12 +8,13 @@ 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,6})?$') +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])' - '(\D?([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3,6})?' - '([zZ]|([\+-])([01]\d|2[0-3])\D?([0-5]\d)?)?)?$' + 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+)$') From f1f4973015f5d4078694f545f8daa624f7674989 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Tue, 9 Sep 2014 09:56:33 -0400 Subject: [PATCH 09/13] document TypeConfig class --- jsonobject/base.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/jsonobject/base.py b/jsonobject/base.py index 7d56d82..4e5fae9 100644 --- a/jsonobject/base.py +++ b/jsonobject/base.py @@ -595,6 +595,34 @@ def set_settings(cls, 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 {} From 7c49c720ec7c285adf985fa2ba30ac5c2d4d9082 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Tue, 9 Sep 2014 11:24:14 -0400 Subject: [PATCH 10/13] change version to beta --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8ce909a..7b39183 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='jsonobject', - version='0.6.0', + version='0.6.0b1', author='Danny Roberts', author_email='droberts@dimagi.com', description='A library for dealing with JSON as python objects', From b7c0d60d9511be8437d220216bc757a4dcc92e12 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Tue, 9 Sep 2014 11:30:02 -0400 Subject: [PATCH 11/13] remove stale comment --- jsonobject/base.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/jsonobject/base.py b/jsonobject/base.py index 4e5fae9..5f87bec 100644 --- a/jsonobject/base.py +++ b/jsonobject/base.py @@ -673,11 +673,6 @@ 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 - cls = type.__new__(mcs, name, bases, dct) cls.__configure(**{key: value From 77e0af3efa756ff8fb40298b3fb271456556dc92 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Tue, 9 Sep 2014 11:33:21 -0400 Subject: [PATCH 12/13] better assert --- jsonobject/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jsonobject/base.py b/jsonobject/base.py index 5f87bec..772d831 100644 --- a/jsonobject/base.py +++ b/jsonobject/base.py @@ -715,7 +715,8 @@ def __new__(mcs, name, bases, dct): def __configure(cls, properties=None, string_conversions=None, update_properties=None): super_settings = get_settings(super(cls, cls)) - assert len(filter(None, (properties, update_properties))) <= 1 + 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.update(properties=update_properties) From 64d3a4d5548e5ecaa3f7b31662067a077125b210 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Tue, 9 Sep 2014 11:52:02 -0400 Subject: [PATCH 13/13] change TypeConfig.update to updated --- jsonobject/base.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/jsonobject/base.py b/jsonobject/base.py index 772d831..a133492 100644 --- a/jsonobject/base.py +++ b/jsonobject/base.py @@ -642,7 +642,14 @@ def replace(self, properties=None, string_conversions=None): else self._string_conversions) ) - def update(self, properties=None, string_conversions=None): + 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: @@ -719,7 +726,7 @@ def __configure(cls, properties=None, string_conversions=None, "{} {}".format(properties, update_properties) type_config = super_settings.type_config if update_properties is not None: - type_config = type_config.update(properties=update_properties) + 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: