"""
This is a module that extends Python's standard builtin json module. It offers
the following additional features:
- `JsonableClassMixin`, a mixin that helps with creating serializable classes
- An extended JSON encoder that can handle classes derived from `JsonableClassMixin`
- `dump` and `dumps` functions that use the extended encoder by default.
"""
import copy
import json as builtin_json
from json import JSONDecodeError # noqa: F401
from schrodinger import get_mmshare_version
from schrodinger.infra import util
from schrodinger.utils import future
NATIVE_JSON_DATATYPES = (str, int, float, bool, type(None), dict, list)
VERSION_KEY = '_version_'
VALUE_KEY = '_value_'
# Attribute name to store the version of an object when calling `.toJson()`
_TO_VERSION_ATTRNAME = '_TO_VERSION_'
# Attribute name to store the version of an object when calling `.fromJson()`
_FROM_VERSION_ATTRNAME = '_FROM_VERSION_'
[docs]class JSONEncodeError(ValueError):
pass
# We create simple subclasses of all the native json datatypes so we can
# attach version attributes to them.
_attributable_types = {}
for type_ in NATIVE_JSON_DATATYPES:
if type_ is bool or type_ is type(None):
# bool and NoneType can't be subclassed, so we don't have to worry
# about adding versions to them.
continue
[docs] class AttributableType(type_):
pass
_attributable_types[type_] = AttributableType
def _unwrap_attributable_type(attributable_inst):
orig_dataclass = type(attributable_inst).mro()[1]
return orig_dataclass(attributable_inst)
def _add_version_attribute(json_obj, version, attr_name, recursive=False):
"""
Create a new wrapped version of `json_obj` that has the attribute
`attr_name` set to the `version`. If `recursive` is set to True
and the `json_obj` is a list or dict, then all values of the `json_obj`
will also have a version attribute.
.. NOTE:
Booleans and `None`s can't be subclassed and it doesn't really
make sense to note versions on them anyways since they're singletons,
so we just return them unmodified.
:param json_obj: A json data structure
:type json_obj: An instance of one of the `NATIVE_JSON_DATATYPES`
:param version: The version of the structure, typically the mmshare
version.
:param attr_name: The attribute name to set on `json_obj`
:type attr_name: str
:param recursive: Whether to recursively set the version attribute.
:type recursive: bool
"""
if type(json_obj) is bool or json_obj is None:
return json_obj
AttributableType = _attributable_types[type(json_obj)]
versioned_obj = AttributableType(json_obj)
setattr(versioned_obj, attr_name, version)
if recursive and isinstance(versioned_obj, (dict, list)):
if isinstance(versioned_obj, dict):
items = versioned_obj.items()
else:
items = enumerate(versioned_obj)
for key, value in items:
versioned_obj[key] = _add_version_attribute(value,
version,
attr_name,
recursive=True)
return versioned_obj
[docs]class JsonableClassMixin:
"""
A mixin that aids in making a class Jsonable. Users must define
`toJsonImplementation(self)` and `fromJsonImplementation(cls, json_dict)`.
For example::
class Person(JsonableClassMixin, object):
def __init__(self, full_name, age):
self.full_name = full_name
self.age = age
def toJsonImplementation(self):
return {'full_name':self.full_name,
'age':self.age}
@classmethod
def fromJsonImplementation(cls, json_dict):
return cls(json_dict['full_name'], json_dict['age'])
Now `dump` and `dumps` can encode :code:`Person`::
# Encode to a file
abe = Person('Abraham Lincoln', 208)
with open('abe.json', 'w') as out_file:
json.dump(abe, out_file)
# Encode to a string
abe_json_string = json.dumps(abe)
If you want to decode the json string or file, you'll have to use
`loads` or `load` and then feed in the result to your class' public
class method `fromJson()`::
# Loading an object from a json file
with open('abe.json', 'r') as in_file:
abe = json.load(in_file, DataClass=Person)
# Loading an object from a json string
abe = json.loads(abe_json_string, DataClass=Person)
"""
# TODO: Add class constant that chooses whether to enforce strict json key
# conversion.
def __init_subclass__(cls):
if 'toJson' in cls.__dict__ or 'fromJson' in cls.__dict__:
err_msg = ('toJson() and fromJson() are not intended to be '
'overridden. To customize toJson() behavior, override '
'toJsonImplementation(); to customize fromJson() '
'behavior, override fromJsonImplementation().')
raise TypeError(err_msg)
cls._setJsonAdapters()
super().__init_subclass__()
[docs] def toJsonImplementation(self):
"""
Abstract method that must be defined by all derived classes. Converts
an instance of the derived class into a jsonifiable object.
:return: A dict made up of JSON native datatypes or Jsonable objects.
See the link below for a table of such types.
https://docs.python.org/2/library/json.html#encoders-and-decoders
"""
raise NotImplementedError()
[docs] @classmethod
def fromJsonImplementation(cls, json_dict):
"""
Abstract method that must be defined by all derived classes. Takes
in a dictionary and constructs an instance of the derived class.
:param json_dict: A dictionary loaded from a JSON string or file.
:type json_dict: dict
:return: An instance of the derived class.
:rtype: cls
"""
raise NotImplementedError()
[docs] def toJson(self, _mark_version=True):
"""
Create and returns a data structure made up of jsonable items.
:rtype: An instance of one the classes from NATIVE_JSON_DATATYPES
"""
d = self.toJsonImplementation()
value = _jsonify(d)
if _mark_version:
return _add_version_attribute(value,
version=self.get_version(),
attr_name=_TO_VERSION_ATTRNAME)
else:
return value
[docs] @classmethod
def fromJson(cls, json_obj):
"""
A factory method which constructs a new object from a given dict
loaded from a json string or file.
:param json_obj: A json-loaded dictionary to create an object from.
:type json_obj: dict
:return: An instance of this class.
:rtype: cls
"""
if hasattr(json_obj, _FROM_VERSION_ATTRNAME):
# If the attribute storing the version is present, then
# we're at the top level of the decoding. We unwrap
# the object into its normal datatype and then save
# the version as an attribute of this function.
version = getattr(json_obj, _FROM_VERSION_ATTRNAME)
json_obj = _unwrap_attributable_type(json_obj)
cls.fromJson.__func__.version = version
else:
# If the attribute isn't present, then check on this function
# to see if its been stored already.
version = cls.fromJson.__func__.version
if version is not None:
adapters = cls._getJsonAdapters()
for ad in adapters:
if ad.json_adapter_version > version:
json_obj = ad(json_obj)
else:
json_obj = copy.deepcopy(json_obj)
try:
cls.fromJson.__func__._stack_count += 1
return cls.fromJsonImplementation(json_obj)
finally:
cls.fromJson.__func__._stack_count -= 1
if cls.fromJson.__func__._stack_count == 0:
cls.fromJson.__func__.version = None
# Save the version of the json object we're processing as an
# attribute on the function. This should be fine since there shouldn't
# ever be an instance where two separate objects are being deserialized
# simultaneously.
fromJson.__func__.version = None
# Use a counter for keeping track of recursion depth so we don't reset
# `fromJson.__func__.version` before all child objects get a chance to be
# deserialized.
fromJson.__func__._stack_count = 0
@classmethod
def _getJsonAdapters(cls):
"""
:return: A list of all adapter methods for this class.
:rtype: list(function)
"""
json_adapters_attr_name = '_' + cls.__name__ + '_jsonAdapters'
adapters = getattr(cls, json_adapters_attr_name, None)
if adapters is None:
cls._setJsonAdapters()
return getattr(cls, json_adapters_attr_name)
@classmethod
def _setJsonAdapters(cls):
"""
Sets the class attribute storing a list of all json adapter methods.
"""
json_adapters_attr_name = '_' + cls.__name__ + '_jsonAdapters'
adapters = util.find_decorated_methods(cls, 'json_adapter_version')
# Ensure that the versions are comparable
if len({type(adapter.json_adapter_version) for adapter in adapters
}) > 1:
raise TypeError('Adapter versions must all be of the same type')
# Sort the adapters
adapters.sort(key=lambda a: a.json_adapter_version)
setattr(cls, json_adapters_attr_name, adapters)
[docs] def get_version(self):
"""
Method to get the version of a particular object. Defaults to
the current version of mmshare. This class can be overridden for custom
versioning behavior.
"""
return get_mmshare_version()
[docs]def adapter(version):
"""
This function is a decorator used to define an adapter function that takes
in a `json_obj` from an older version of a `JsonableClassMixin` and
returns it modified such that it can be read in by the version of the class
specified by the `version` argument (typically the current version at the
time the adapter is written).
As a example, imagine we define a simple class::
class Person:
# mmshare version: 40000
def __init__(self, full_name):
self.full_name = full_name
If, in mmshare version 41000, we split :code:`full_name` into attributes
:code:`first_name` and :code:`last_name`, we could define an adapter like so::
class Person:
# mmshare version: 41000
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
@json.adapter(version=41000)
def _JsonAdapter(self, json_dict):
full_name = json_dict.pop(full_name)
fname, lname = full_name.split(' ')
json_dict['first_name'] = fname
json_dict['last_name'] = lname
return json_dict
.. note::
An adapter function only needs to bring the `json_dict` up-to-date with
the class *at the time that the adapter is written*. The next time the
class is changed in a way that breaks json decoding, a new adapter
function should be added to the class that takes in the previous
adapter's output and makes it compatible with the current class. In
this way the json framework can decode any older version by
automatically passing it through the appropriate chain of adapter
functions.
:param version: The data version to which an older `json_dict` will be
adapted (typically the current version when the adapter is writte).
:type version: int, str, or list of str or ints
:raises TypeError: if `version` is not an int, str, or list
"""
if type(version) not in {int, str, list}:
raise TypeError('Adapter versions must an int, str, or list')
if type(version) is list and any(
not isinstance(item, (int, str)) for item in version):
raise TypeError(
'Adapter version lists must be made up of only ints or strings')
def wrapped_adapter(f):
f.json_adapter_version = version
return classmethod(f)
return wrapped_adapter
def _dict_to_json(d):
new_d = {}
for k, v in d.items():
if not isinstance(k, str):
raise JSONEncodeError(
'The JSON protocol only supports using strings '
'as keys of dictionaries')
new_d[k] = _jsonify(v)
return new_d
def _list_to_json(lst):
new_l = []
for idx, v in enumerate(lst):
new_l.append(_jsonify(v))
return new_l
# JsonableClassMixin is defined in this module, so we can't define this
# constant at the top.
JSONABLE_DATATYPES = NATIVE_JSON_DATATYPES + (JsonableClassMixin,)
def _jsonify(val):
"""
Convert `val` into a jsonable data structure. `val` must either subclass
`JsonableClassMixin` or be a jsonable data type. If `val` is a dict or
list, or `val.toJson()` returns a dict or list, then this will recursively
go through the dict or list values and jsonify them.
:return: A jsonable data structure.
:raises JsonEncodeError: if val is not jsonable.
"""
if (not isinstance(val, JsonableClassMixin) and
type(val) in jsonable.DATACLASS_REGISTRY):
serializer = jsonable.get_default_serializer(type(val))
val = serializer.jsonableFromObject(val)
if not isinstance(val, JSONABLE_DATATYPES):
# TODO: This ideally causes an error on class definition or
# instantiation, similar to an ABCMethod
JSONABLE_DATATYPE_NAMES = ' '.join(
cls.__name__ for cls in JSONABLE_DATATYPES)
err_msg = (
f"type <{type(val).__name__}> is not jsonable. To be jsonable, "
"an object must be an instance of one of the following types: " +
JSONABLE_DATATYPE_NAMES)
raise JSONEncodeError(err_msg)
if isinstance(val, JsonableClassMixin):
val = val.toJson(_mark_version=False)
if isinstance(val, dict):
return _dict_to_json(val)
elif isinstance(val, list):
return _list_to_json(val)
else:
return val
[docs]def get_json_version_from_file(json_fh):
"""
Get the version information from a json file serialized from
`JsonableClassMixin` objects. Returns None if no version information is
found.
:type json_fh: file handle open for reading
"""
json_obj = load(json_fh)
return getattr(json_obj, _FROM_VERSION_ATTRNAME, None)
[docs]def get_json_version_from_string(json_str):
"""
Get the version information from a json string serialized from
`JsonableClassMixin` objects. Returns None if no version information is
found.
"""
json_obj = loads(json_str)
return getattr(json_obj, _FROM_VERSION_ATTRNAME, None)
################################################################################
# Wrappers for the standard libs json module
################################################################################
# Import down here to prevent circular import
from . import jsonable # isort:skip
def _encode(obj, serializer=None):
"""
If the object implements JsonableClassMixin, Wrap the object in a
dictionary mapping json.VALUE_KEY to the object and json.VERSION_KEY to the
version of the object. Otherwise, use the given serializer to turn the
object into a serializable version of the object. If neither of these
conditions are met, simply return the object.
"""
# If the object hasn't already been converted into a jsonable
# structure and implements JsonableClassMixin, then call toJson.
if (not isinstance(obj, JsonableClassMixin) and
type(obj) in jsonable.DATACLASS_REGISTRY and serializer is None):
serializer = jsonable.get_default_serializer(type(obj))
if serializer is not None:
obj = serializer.jsonableFromObject(obj)
if isinstance(obj, JsonableClassMixin):
obj = obj.toJson()
elif isinstance(obj,
(dict, list)) and not hasattr(obj, _TO_VERSION_ATTRNAME):
obj = _jsonify(obj)
# If the object has already had `toJson()` called on it, it'll
# have the version attached to it.
if hasattr(obj, _TO_VERSION_ATTRNAME):
version = getattr(obj, _TO_VERSION_ATTRNAME)
obj = {VERSION_KEY: version, VALUE_KEY: obj}
return obj
[docs]def decode(obj, DataClass=None, serializer=None):
"""
Decode the `obj` from a json structure to the original object that encoded
it. This is done using the following methods in order of priority:
1) Detecting whether the jsoned object originally implemented
`JsonableClassMixin`. If it was, decode it into a normal json object
and retrieve the version number.
2) Using `DataClass` if it's a `typing` annotation generic (e.g.
`typing.List`, `typing.Tuple`, etc)
3) Using the supplied `serializer`
4) Inferring a serializer using `DataClass`
"""
# Detect whether the object was originally a `JsonableClassMixin` type
# by checking whether it's a dict that has a key VERSION_KEY
if isinstance(obj, dict) and VERSION_KEY in obj:
version = obj[VERSION_KEY]
obj = obj[VALUE_KEY]
obj = _add_version_attribute(obj,
version,
_FROM_VERSION_ATTRNAME,
recursive=False)
if DataClass is not None:
if DataClass is type(obj):
return obj
elif isinstance(DataClass, type) and issubclass(DataClass,
JsonableClassMixin):
return DataClass.fromJson(obj)
elif future.get_origin(DataClass) in {set, list, dict, tuple}:
obj = _decode_generic(obj, DataClass)
else:
serializer = jsonable.get_default_serializer(DataClass)
if serializer is not None:
return serializer.objectFromJson(obj)
return obj
def _decode_generic(obj, generic_class):
generic = future.get_origin(generic_class)
generic_args = future.get_args(generic_class)
if generic is tuple:
if generic_args:
if len(generic_args) != len(obj) - 1:
raise TypeError(
f"{len(obj)-1} values specified for {generic_class}")
return tuple(
decode(v, DataClass=arg) for v, arg in zip(obj, generic_args))
else:
return decode(obj, DataClass=tuple)
elif generic is list:
if generic_args:
list_member_type = generic_args[0]
return [decode(v, DataClass=list_member_type) for v in obj]
else:
return decode(obj, DataClass=list)
elif generic is set:
if generic_args:
list_member_type = generic_args[0]
set_values = [
decode(v, DataClass=list_member_type) for v in obj[:-1]
]
else:
set_values = decode(obj, DataClass=set)
return set(set_values)
elif generic is dict:
if generic_args:
key_type = generic_args[0]
value_type = generic_args[1]
return {
decode(k, DataClass=key_type): decode(v, DataClass=value_type)
for k, v in obj.items()
}
else:
return obj
[docs]def dumps(obj, serializer=None, **kwargs):
"""
A wrapper that automatically encodes objects derived from
`JsonableClassMixin`.
:param serializer: A custom serializer to use for serializing the object.
This should not be used at the same time as the `DataClass` argument.
:type serializer: jsonable.AbstractJsonSerializer
If `obj` does not subclass `JsonableClassMixin`, then this function
will behave exactly like the builtin `json.dumps`.
"""
obj = _encode(obj, serializer)
return builtin_json.dumps(obj, **kwargs)
[docs]def dump(obj, fp, serializer=None, **kwargs):
"""
A wrapper that automatically encodes objects derived from
`JsonableClassMixin`.
:param serializer: A custom serializer to use for serializing the object.
This should not be used at the same time as the `DataClass` argument.
:type serializer: jsonable.AbstractJsonSerializer
If `obj` does not subclass `JsonableClassMixin`, then this function
will behave exactly like the builtin `json.dump`.
"""
obj = _encode(obj, serializer)
return builtin_json.dump(obj, fp, **kwargs)
[docs]def loads(json_str, DataClass=None, serializer=None, **kwargs):
"""
A wrapper that automatically decodes json strings serialized from
`JsonableClassMixin` objects.
:param DataClass: The class of the object that was serialized into
`json_str`. The class must either be in JSONABLE_DATATYPES or a type
that's been registered in `schrodinger.models.jsoable`.
:param serializer: A custom serializer to use for deserializing the string.
This should not be used at the same time as the `DataClass` argument.
:type serializer: jsonable.AbstractJsonSerializer
If the json string was not encoded `JsonableClassMixin` object, this
function will behave exactly like the builtin `json.loads`.
"""
obj = builtin_json.loads(json_str, **kwargs)
obj = decode(obj, DataClass, serializer)
return obj
[docs]def load(fp, DataClass=None, serializer=None, **kwargs):
"""
A wrapper that automatically decodes json files serialized from
`JsonableClassMixin` objects.
:param DataClass: The class of the object that was serialized into
`fp`. The class must either be in JSONABLE_DATATYPES or a type
that's been registered in `schrodinger.models.jsonable`.
:param serializer: A custom serializer to use for deserializing the file.
This should not be used at the same time as the `DataClass` argument.
:type serializer: jsonable.AbstractJsonSerializer
If the json file was not encoded `JsonableClassMixin` object, this
function will behave exactly like the builtin `json.load`.
"""
obj = builtin_json.load(fp, **kwargs)
obj = decode(obj, DataClass, serializer)
return obj
JSONDecoder = builtin_json.JSONDecoder
JSONEncoder = builtin_json.JSONEncoder