Skip to content

Instantly share code, notes, and snippets.

@SpotlightKid
Created March 11, 2015 19:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save SpotlightKid/047442b650c015563877 to your computer and use it in GitHub Desktop.
Save SpotlightKid/047442b650c015563877 to your computer and use it in GitHub Desktop.
JSON en-/decoder with support for datetime/date and custom object instances.
# -*- 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)
#!/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