Created
March 11, 2015 19:30
-
-
Save SpotlightKid/047442b650c015563877 to your computer and use it in GitHub Desktop.
JSON en-/decoder with support for datetime/date and custom object instances.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- coding: utf-8 -*- | |
# | |
# jsonify.py | |
# | |
"""JSON en-/decoder with support for datetime/date and custom object instances. | |
Encodes/decodes date/datetime instances to/from ISO-8601 format or, if the | |
python-dateutil_ package is installed, any format that ``dateutil.parser`` | |
understands. | |
Encodes Python class instances which have a ``__json__`` method returning a | |
JSON-encodable object of the instance state. Provides the ``Jsonable`` class, | |
which can be used as a mix-in to add an implementation of the ``__json__`` | |
method that works for many objects. | |
Object serialization and de-serialization can be further customized by using | |
the `pickle API`_ described in the standard library reference, in particular by | |
implementing any of the special methods ``__getstate___``, ``__setstate__`` and | |
``__getnewargs__``. | |
See the documentation of the ``JsonifyEncoder`` and ``JsonifyDecopder`` classes | |
and the example code at the end of the module for usage information. | |
.. _python-dateutil: http://labix.org/python-dateutil | |
.. _pickle api: | |
https://docs.python.org/2/library/pickle.html#pickling-class-instances | |
""" | |
from __future__ import absolute_import, print_function, unicode_literals | |
import datetime | |
import json | |
import re | |
try: | |
from dateutil.parser import parse as parse_date | |
_have_dateutil = True | |
except ImportError: | |
_have_dateutil = False | |
# see https://gist.github.com/SpotlightKid/8c7f73cb8f936dc54c04 | |
from parsedate import parse_isodate | |
__all__ = ('Jsonable', 'JsonifyDecoder', 'JsonifyEncoder', 'dumps', 'loads') | |
try: | |
basestring | |
except NameError: | |
basestring = str | |
DAYFIRST = True | |
DATETIME_RX = re.compile( | |
r"(\d{4}-[01]\d-[0-3]\d)" # date | |
r"(T([0-2]\d:[0-6]\d:[0-6]\d(.\d+))?" # time | |
r"([-+][0-2]\d:[0-6]\d)?)?") # UTC offset | |
class Jsonable(object): | |
"""Mixin class providing JSON serialization support for class instances.""" | |
def __json__(self): | |
"""Return JSON-serializable representation of object state. | |
Returns dict of attribute name/value pairs with the class name of the | |
instance added under the key ``'__class__'``. | |
If the object has a ``__dir__`` method, it is expectd to return an | |
iterable of the names of the attributes, which make up the returned | |
state dict. Otherwise the list of attributes is obtained by calling | |
``dir()`` on the instance and filtering out any attributes whose name | |
starts with two underscores. | |
""" | |
attrlister = getattr(self, '__dir__', None) | |
if attrlister: | |
attrlist = attrlister() | |
attrlist = [a for a in self.__dict__ if not a.startswith('__')] | |
attr = dict((attr, getattr(self, attr)) for attr in attrlist) | |
attr['__class__'] = self.__class__.__name__ | |
return attr | |
class JsonifyEncoder(json.JSONEncoder): | |
"""Subclass of json.JSONEncoder, which supports additional types. | |
This encoder supports serializing custom class and datetime/date instances | |
in combination with the ``Jsonable`` mixin. | |
""" | |
def default(self, obj): | |
"""Encode objects to JSON for which no native conversion is defined.""" | |
for method in ('__json__', '__getstate__'): | |
jsonify = getattr(obj, method, None) | |
if callable(jsonify): | |
return jsonify() | |
if isinstance(obj, (datetime.date, datetime.datetime)): | |
return obj.isoformat() | |
else: | |
return super(JsonifyEncoder, self).default(obj) | |
class JsonifyDecoder(json.JSONDecoder): | |
"""JSON decoder parsing datetime strings and special object notation.""" | |
def __init__(self, namespace=None, datetime_literals=True, | |
strict_isodate=True, **kw): | |
"""Create decoder instance.""" | |
kw.setdefault('object_hook', self.decode_object) | |
super(JsonifyDecoder, self).__init__(**kw) | |
self._namespace = namespace or {} | |
self.datetime_literals = datetime_literals | |
self.strict_isodate = strict_isodate or (not _have_dateutil) | |
def parse_date(self, datestr): | |
"""Parse datestr and return a datetime or date instance. | |
If the ``strict_isodate`` is ``True`` (the default), ``datestr`` must | |
be an ISO-8601 formatted string containing either a full timestamp with | |
date and time components or only a full date component. The | |
microseconds and UTC offset parts of the time component are optional. | |
Depending on the presence of a time component, either a | |
``datetime.date`` or a ``datetime.datetime`` will be returned. | |
If ``strict_isodate`` is ``False``, ``datestr`` may contain any | |
date/time representation, which can be parsed by ``datetutil.parser``, | |
and a ``datetime.datetime`` instance will be returned. | |
If ``datestr`` can't be parsed, raises a ``ValueError``. | |
""" | |
if self.strict_isodate: | |
return parse_isodate(datestr) | |
else: | |
return parse_date(datestr, dayfirst=DAYFIRST) | |
def decode_object(self, obj, namespace=None): | |
"""Decode dict or list resulting from JSON object notation. | |
Please note that if the ``datetime_literals`` attribute is ``True`` | |
(the default), decoding is about five times slower than if not. The | |
factor depends on the ratio of datetime literals and normal strings, | |
since every string inside an array or object in the JSON representation | |
must be examined as a possible datetime literal and creation of | |
datetime instances is comparatively slow. | |
""" | |
if isinstance(obj, list): | |
pairs = enumerate(obj) | |
elif isinstance(obj, dict): | |
pairs = obj.items() | |
result = [] | |
for key, val in pairs: | |
if self.datetime_literals and isinstance(val, basestring): | |
if DATETIME_RX.match(val): | |
try: | |
val = self.parse_date(val) | |
except ValueError: | |
pass | |
elif isinstance(val, (dict, list)): | |
val = self.decode_object(val, namespace) | |
result.append((key, val)) | |
if isinstance(obj, list): | |
return [x[1] for x in result] | |
elif isinstance(obj, dict): | |
result = dict(result) | |
clsname = result.pop('__class__', None) | |
if clsname: | |
try: | |
cls = getattr(self._namespace, clsname) | |
except AttributeError: | |
cls = self._namespace.get(clsname) | |
if cls is None: | |
raise NameError("name '%s' not found in given namespace." % | |
clsname) | |
inst = cls.__new__(cls, *result.pop('__newargs__', ())) | |
try: | |
getattr(inst, '__setstate__')(result) | |
except AttributeError: | |
for k in result: | |
if not k.startswith('__'): | |
setattr(inst, k, result[k]) | |
return inst | |
else: | |
return result | |
def dumps(obj, **kw): | |
"""Serialize obj to a JSON formatted str with support for class instances. | |
Keyword arguments are passed to ``json.dumps``, which inturn passes any | |
keyword arguments it doesn't recognize to the init method of the JSON | |
encoder class, which is set to ``JsonifyEncoder`` by default. | |
""" | |
kw.setdefault('cls', JsonifyEncoder) | |
return json.dumps(obj, **kw) | |
def loads(s, namespace=None, **kw): | |
"""Deserialize string to a Python object with support for class instances. | |
*namespace* is a dict-like or module object, which is used to resolve class | |
names of JSON-ified class instances. By default the global *module* | |
namespace is used, which probably isn't what you want. Normally you would | |
pass something like the return value of ``globals()`` or ``locals()``. | |
The remaining keyword arguments are passed to ``json.loads``, which in turn | |
passes any keyword arguments it doesn't recognize to the init method of | |
the JSON decoder class, which is set to ``JsonifyDecoder`` by default and | |
can be changed by passing a decoder class via the ``cls`` keyword argument. | |
""" | |
if namespace is None: | |
namespace = globals() | |
kw.setdefault('cls', JsonifyDecoder) | |
kw['namespace'] = namespace | |
return json.loads(s, **kw) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
# -*- coding:utf-8 -*- | |
"""Unit test suite for jsonify module.""" | |
from __future__ import absolute_import, print_function, unicode_literals | |
import datetime | |
from jsonify import Jsonable, dumps, loads | |
class Bunch(Jsonable): | |
def __init__(self, **kw): | |
self.__dict__.update(kw) | |
def __repr__(self): | |
return "<Bunch %r>" % (self.__dict__,) | |
TEST_JSON = '{"alt_date": "20.3.2014", "instance": {"timestamp3": "2015-01-16T08:59:15.799340", "__class__": "Bunch", "ham": 23, "spamm": "eggs"}, "struct": {"timestamp2": "2015-01-16T08:59:15.799340", "date2": "2015-01-16"}, "date": "2015-01-16", "timestamp": "2015-01-16T08:59:15.799340", "array": ["2015-01-16T08:59:15.799340", "2015-01-16"], "foo": 42}' | |
def test_jsonable(): | |
"""Testing Jsonable class.""" | |
mytimestamp = datetime.datetime(2015, 1, 16, 8, 59, 15, 799340) | |
mydate = datetime.date(2015, 1, 16) | |
data = dict( | |
foo=42, | |
array=[mytimestamp, mydate], | |
date=mydate, | |
timestamp=mytimestamp, | |
alt_date="20.3.2014", | |
instance=Bunch( | |
spamm='eggs', | |
ham=23, | |
timestamp3=mytimestamp | |
), | |
struct=dict( | |
date2=mydate, | |
timestamp2=mytimestamp | |
) | |
) | |
print("Python input:\n") | |
print(repr(data)) | |
jsonstring = dumps(data) | |
print("JSON string:\n") | |
print(jsonstring) | |
assert jsonstring.strip() == TEST_JSON.strip() | |
print("Python from JSON input:\n") | |
result = loads(jsonstring, namespace={'Bunch': Bunch}) | |
print(repr(result)) | |
assert isinstance(result, dict) | |
assert isinstance(result['foo'], int) | |
assert isinstance(result['array'], list) | |
assert isinstance(result['date'], datetime.date) | |
assert isinstance(result['timestamp'], datetime.datetime) | |
assert isinstance(result['alt_date'], unicode) | |
assert isinstance(result['instance'], Bunch) | |
assert isinstance(result['instance'].timestamp3, datetime.datetime) | |
if __name__ == '__main__': | |
import nose | |
nose.main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment