Source code for gillespy2.core.jsonify

# GillesPy2 is a modeling toolkit for biochemical simulation.
# Copyright (C) 2019-2023 GillesPy2 developers.

# 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 re
import copy
import json
import pydoc
import hashlib

from json import JSONEncoder

import numpy
import gillespy2

[docs]class Jsonify: """ Interface to allow for instances of arbitrary types to be encoded into json strings and decoded into new objects. """ _translation_table = None
[docs] def to_json(self, encode_private=True) -> str: """ Convert self into a json string. :param encode_private: If True all private (prefixed of '_') will be encoded. None if False. :type encode_private: bool :returns: The JSON representation of self. """ encoder = ComplexJsonCoder(encode_private=encode_private) return json.dumps(copy.deepcopy(self), indent=4, sort_keys=True, default=encoder.default)
[docs] @classmethod def from_json(cls, json_str: str) -> object: """ Convert some json_str into a decoded Python type. This function should return a new instance of the type. :param json_str: A json str to be converted into a new type instance. :type json_str: str :returns: A decoded object. """ # If the json_str is actually a dict, it means we've decoded as much as possible. if isinstance(json_str, dict): return cls.from_dict(json_str) decoder = ComplexJsonCoder() return json.loads(json_str, object_hook=decoder.decode)
[docs] def to_dict(self) -> dict: """ Convert the object into a dictionary ready for json encoding. Note: Complex types that inherit from Jsonify do not need to be manually encoded. By default, this function will return a dictionary of the object's public types. :returns: The backing var dictionary of the object. """ return self.__dict__.copy()
[docs] @classmethod def from_dict(cls, src_dict: dict) -> object: """ Convert some dict into a new instance of a python type. This function will return a __new__ instance of the type. :param src_dict: The dictionary to apply onto the new instance. :type src_dict: dict :returns: A new object with its backing __dict__ set to a copy of src_dict. """ new = cls.__new__(cls) new.__dict__ = src_dict.copy() return new
[docs] def to_anon(self): """ Converts self into an anonymous instance of self. """ return self.get_translation_table().obj_to_anon(copy.deepcopy(self))
[docs] def to_named(self): """ Converts self into a named instance of self. """ return self.get_translation_table().obj_to_named(copy.deepcopy(self))
[docs] def get_translation_table(self) -> "TranslationTable": """ Make and/or return the translation table. :returns: A TranslationTable instance. """ if self._translation_table is None: self._translation_table = self.make_translation_table() return self._translation_table
[docs] def make_translation_table(self) -> "TranslationTable": """ Make a translation table that describes key:value pairs to convert user-define data into generic equivalents. :returns: A newly generated TranslationTable instance. """ raise NotImplementedError("make_translation_table() has not been implemented.")
[docs] def public_vars(self) -> dict: """ Gets a dictionary of public vars that exist on self. Keys starting with '_' are ignored. :returns: A dictionary containing all public ('_'-prefixed) variables on the object. """ return {k: v for k, v in vars(self).items() if not k.startswith("_")}
[docs] def get_json_hash(self, ignore_whitespace=True, hash_private_vals=False) -> str: """ Get the hash of the json representation of self. :param ignore_whitespace: If set to True all whitespace will be stripped from the JSON prior to being hashed. :type ignore_whitespace: bool :param hash_private_vals: If set to True all private and non-private variables will be included in the hash. :type hash_private_vals: bool :returns: An MD5 hash of the object's sorted JSON representation. """ # If _hash_private_vars is set, hash ALL properties on the object. model_json = self.to_json(encode_private=hash_private_vals) # If ignore_whitespace is set, strip out all whitespace characters. if ignore_whitespace: model_json = re.sub(r"\s+", "", model_json) return hashlib.md5(str.encode(model_json)).hexdigest()
def __eq__(self, obj: "Jsonify"): """ Overload to compare the json of two objects that derive from Jsonify. This method will not do any additional translation. :param obj: The Jsonify object to compare against. :type obj: Jsonify :returns: True if equal, False if not. """ return self.get_json_hash() == obj.get_json_hash()
[docs]class ComplexJsonCoder(JSONEncoder): """ This class delegates the encoding and decoding of objects to one or more implementees. :param translation_table: A TranslationTable instance that will be used to translate objects. :type translation_table: TranslationTable :param encode_private: If set to True then all private and public variables will be converted to JSON. If False, only public. :type encode_private: bool """ def __init__(self, translation_table=None, encode_private=True, **kwargs): super().__init__(**kwargs) self._translation_table = translation_table self._encode_private = encode_private self._delegation_table = { numpy.ndarray: NdArrayCoder, numpy.int64: Int64Coder, set: SetCoder, type: TypeCoder }
[docs] def default(self, o: object): """ This function is called when json.dumps() fires. default() is a bad name for the function, but anything else makes JSONEncoder freak out. :param o: The object that is currently being encoded into JSON. """ # If o is of matching type, use a custom coder. for obj_type, coder in self._delegation_table.items(): if isinstance(o, obj_type): return coder.to_dict(o) if not isinstance(o, Jsonify): return super().default(o) if self._encode_private: model = o.to_dict() else: # Strip private variables from the object. model = {} for key, val in o.to_dict().items(): if key.startswith("_") and not key.startswith("__"): continue model[key] = val # If the model is some subclass of gillespy2.core.model.Model, then manually set its type. if issubclass(o.__class__, gillespy2.core.Model): model["_type"] = f"{gillespy2.core.Model.__module__}.{gillespy2.core.Model.__name__}" else: model["_type"] = f"{o.__class__.__module__}.{o.__class__.__name__}" return model
[docs] def decode(self, json_dict: dict): """ Decode the JSON dictionary into a valid Python object. :param json_dict: The JSON dictionary to decode. :type json_dict: dict :returns: The decoded form of the JSON dictionary. """ # _type is a field embedded by the encoder to indicate which # Jsonify instance will be used to decode the json string. if "_type" not in json_dict: return json_dict json_type = pydoc.locate(json_dict["_type"]) if json_type is None: raise Exception(f"{json_type} does not exist.") # If the type is not a subclass of Jsonify, throw an exception. # We do this to prevent the execution of arbitrary code. if not issubclass(json_type, Jsonify): raise Exception(f"{json_type}") return json_type.from_json(json_dict)
[docs]class TranslationTable(Jsonify): """ This class contains functions to enable arbitrary object trees to be "translated" to anonymous and named objects. This behavior is defined by a map of 'named' and 'anon' key values. :param to_anon: A mapping of 'named' to 'anonymous' strings to be used when converting user-defined names to anon. :type to_anon: dict[str, str] """ def __init__(self, to_anon: "dict[str, str]"): self.to_anon = to_anon.copy() self.to_named = dict((v, k) for k, v in list(self.to_anon.items()))
[docs] def obj_to_anon(self, obj: object): """ Recursively anonymise all named properties on the object. :param obj: The object to anonymize. :type obj: object :returns: An anonymized instance of self. """ return self.recursive_translate(obj, self.to_anon)
[docs] def obj_to_named(self, obj): """ Recursively identify all anonymous properties on the object. :param obj: The object that will be converted to named. :type obj: object :returns: A named instance of self. """ return self.recursive_translate(obj, self.to_named)
[docs] def recursive_translate(self, obj: object, translation_table: "dict[str, str]"): """ Recursively search through the object tree searching for property value matches in the translation table. If a match is found, substitute. :param obj: The object that will be translated. :type obj: object :param translation_table: The mapping to translate by. :type translation_table: TranslationTable """ # If a translation table exists on the object, remove and save it. if "_translation_table" in obj.__dict__: saved_table = obj.__dict__.pop("_translation_table") translated = self._recursive_translate(obj, translation_table) # Restore the original translation table, if needed. if saved_table is not None: obj.__dict__["_translation_table"] = saved_table return translated
def _recursive_translate(self, obj: object, translation_table: "dict[str, str]"): # The obj is a class if it's an instance of Jsonify. Class property names *cannot* # be changed, so translate just the values. if isinstance(obj, Jsonify): for key in vars(obj).keys(): vars(obj)[key] = self._recursive_translate(vars(obj)[key], translation_table) elif isinstance(obj, list): for item in obj: item = self._recursive_translate(item, translation_table) elif isinstance(obj, dict): # Convert the dictionary into a list of tuples. # This makes it easier to modify key names. obj = list((k, v) for k, v in obj.items()) new_pairs = [ ] for pair in obj: new_pairs.append(( self._recursive_translate(pair[0], translation_table), self._recursive_translate(pair[1], translation_table) )) obj = dict((x[0], x[1]) for x in new_pairs) # If the obj is a string, translate it via a regex replace. # Note: mathematical functions contain additional characters that should not be translated. elif isinstance(obj, str): # To handle functions, grab all complete words from the string. matches = re.finditer("([0-z])+", obj) # For each match, translate the group. for match in matches: group = match.group() obj = obj.replace(group, translation_table.get(group, group)) return obj
[docs]class NdArrayCoder(Jsonify): """ This JSON coder enables support for the `numpy.ndarray` type. """
[docs] @staticmethod def to_dict(obj): return { "data": obj.tolist(), "_type": f"{NdArrayCoder.__module__}.{NdArrayCoder.__name__}" }
[docs] @staticmethod def from_json(obj): return numpy.array(obj["data"])
[docs]class Int64Coder(Jsonify): """ This JSON coder enables support for the `numpy.int64` type. """
[docs] @staticmethod def to_dict(obj): return { "data": int(obj), "_type": f"{Int64Coder.__module__}.{Int64Coder.__name__}" }
[docs] @staticmethod def from_json(obj): return numpy.int64(obj["data"])
[docs]class SetCoder(Jsonify): """ This JSON coder enables support for the `set` type. """
[docs] @staticmethod def to_dict(obj): return { "data": list(obj), "_type": f"{SetCoder.__module__}.{SetCoder.__name__}" }
[docs] @staticmethod def from_json(obj): return set(obj["data"])
[docs]class TypeCoder(Jsonify): """ This JSON coder enables support for the 'type' type. """
[docs] @staticmethod def to_dict(obj): return { "data": type(obj), "_type": f"{TypeCoder.__module__}.{TypeCoder.__name__}" }
[docs] @staticmethod def from_json(obj): return pydoc.locate(obj["data"])