"""
Module containing the :class:`SavesToJSON` abstract class for serializing complex objects back and forth to JSON files.
"""
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ #
# This file is part of SCAMP (Suite for Computer-Assisted Music in Python) #
# Copyright © 2020 Marc Evanstein <marc@marcevanstein.com>. #
# #
# This program is free software: you can redistribute it and/or modify it under the terms of #
# the GNU General Public License as published by the Free Software Foundation, either version #
# 3 of the License, or (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; #
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. #
# See the GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License along with this program. #
# If not, see <http://www.gnu.org/licenses/>. #
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ #
import json
from abc import ABCMeta, abstractmethod
from typing import TypeVar, Type
from copy import deepcopy
T = TypeVar('T', bound='SavesToJSON')
[docs]class SavesToJSON(metaclass=SavesToJSONMeta):
"""
Abstract class that, when implemented, gives the ability to save to and from JSON objects.
Children must implement the ``_to_dict`` and ``_from_dict`` functions which convert back and forth
between an instance and a dictionary representing that instance's data. The data in such a dictionary
may contain only standard json-serializable types (lists, dicts, ints, floats, strings, etc.) and other
objects that implement the SavesToJSON interface,
"""
@abstractmethod
def _to_dict(self) -> dict:
"""
Should define a dictionary representation of this object. Any objects nested within that dictionary must inherit
from SavesToJSON.
"""
pass
@classmethod
@abstractmethod
def _from_dict(cls, json_dict):
"""
Should define how one reconstructs an object of this class from its dictionary representation (as returned by
``_to_dict``).
"""
[docs] def json_dumps(self) -> str:
"""
Dump this object as a JSON string. This uses a custom encoder that recognizes and appropriately converts any
attributes that are object inheriting from SavesToJSON.
"""
return json.dumps(self, default=SavesToJSON._encoder_default, sort_keys=True, indent=4)
[docs] def save_to_json(self, file_path: str) -> None:
"""
Save this object to a JSON file using the given path. This uses a custom encoder that recognizes and
appropriately converts any attributes that are object inheriting from SavesToJSON.
:param file_path: path for saving the file
"""
with open(file_path, "w") as file:
json.dump(self, file, default=SavesToJSON._encoder_default, sort_keys=True, indent=4)
[docs] @classmethod
def json_loads(cls, s: str) -> T:
"""
Load this object from a JSON string. This uses a custom decoder that looks for a "_type" key in any
object/dictionary being parsed and converts it to the class specified (assuming it a subclass of
SavesToJSON).
:param s: a string representing this object in JSON format
"""
out = json.loads(s, object_hook=SavesToJSON._decoder_object_hook)
if cls != SavesToJSON and not isinstance(out, cls):
raise ValueError(
"Trying to load object of type {object_type} using `{correct_type}.json_loads`. Use "
"`{object_type}.json_loads` or generic `SavesToJSON.json_loads` instead.".format(
object_type=type(out).__name__, correct_type=cls.__name__
)
)
return out
[docs] @classmethod
def load_from_json(cls: Type[T], file_path: str) -> T:
"""
Load this object from a JSON file with the given path. This uses a custom decoder that looks for a
"_type" key in any object/dictionary being parsed and converts it to the class specified (assuming it
a subclass of SavesToJSON).
:param file_path: path for loading the file
"""
with open(file_path, "r") as file:
out = json.load(file, object_hook=SavesToJSON._decoder_object_hook)
if cls != SavesToJSON and not isinstance(out, cls):
raise ValueError(
"Trying to load object of type {object_type} using `{correct_type}.load_from_json`. Use "
"`{object_type}.load_from_json` or generic `SavesToJSON.load_from_json` instead.".format(
object_type=type(out).__name__, correct_type=cls.__name__
)
)
return out
@staticmethod
def _decoder_object_hook(json_object):
if "_type" in json_object:
if json_object["_type"] not in SavesToJSONMeta.names_to_types:
raise ValueError("SCAMP object type {} not understood.".format(json_object["_type"]))
object_type = SavesToJSONMeta.names_to_types[json_object["_type"]]
del json_object["_type"]
return object_type._from_dict(json_object)
return json_object
@staticmethod
def _encoder_default(obj):
if hasattr(obj, "_to_dict"):
converted = obj._to_dict()
converted["_type"] = SavesToJSONMeta.types_to_names[type(obj)]
return converted
return obj
[docs] def duplicate(self: T) -> T:
"""
Returns a copy of this object by serializing to and from JSON.
"""
try:
return self._from_dict(deepcopy(self._to_dict()))
except TypeError:
# in some cases deepcopy is not possible, because some parts of the dictionary can't be serialized
# and pickled. In this case, we do the roughly 2x slower option of going to a full JSON string and back
return self.json_loads(self.json_dumps())