'''Tools for making existing classes easily readable/writable to JSON'''
__author__ = 'Timotej Bernat'
__email__ = 'timotej.bernat@colorado.edu'
from typing import (
Any,
ClassVar,
Optional,
Type,
TypeVar,
Union,
get_origin,
get_args,
)
C = TypeVar('C') # generic type for classes
from dataclasses import dataclass, is_dataclass
from functools import wraps
from inspect import signature, isclass
import json
from pathlib import Path
from ..pathutils import allow_string_paths
from .serialize import TypeSerializer, MultiTypeSerializer
# TYPEHINTING AND SERIALIZATION FOR JSONIFIABLE CLASSES
[docs]
class JSONifiable: # documentation class, makes type-checking for jsonifiable-modified classes easier
'''For type-hinting classes which are jsonifiable'''
pass
[docs]
def dataclass_serializer_factory(cls : Type[C]) -> TypeSerializer:
'''For generating a custom TypeSerializer for a JSONifiable dataclass'''
assert(is_dataclass(cls)) # can enforce modification only to dataclasses (makes behavior a little more natural)
class DataclassSerializer(TypeSerializer, python_type=cls):
f'''JSON encoder and decoder for the {cls.__name__} dataclass'''
@staticmethod
def encode(python_obj : C) -> dict[str, Any]:
'''Extract dictionary of attributes (may need other external converters to be fully serialized)'''
return python_obj.__dict__
@staticmethod
def decode(json_obj : dict[str, Any]) -> C:
'''Load registered JSONifiable class from a (presumed to be a dictionary during __values__ parse)'''
json_obj = {
attr : value
for attr, value in json_obj.items()
if cls.__dataclass_fields__[attr].init # only pass fields which are allowed to be initialized with values
}
return cls(**json_obj)
# dynamically update signatures for readability
non_generic_name = f'{cls.__name__}Serializer' # dynamically set the name of the serializer to match with the wrapped class
setattr(DataclassSerializer, '__name__', non_generic_name)
setattr(DataclassSerializer, '__qualname__', non_generic_name)
setattr(DataclassSerializer, '__module__', cls.__module__)
return DataclassSerializer
# DECORATOR METHOD FOR MAKING CLASSES JSON-SERIALIZABLE
[docs]
def make_jsonifiable(cls : Optional[C]=None, type_serializer : Optional[Union[TypeSerializer, MultiTypeSerializer]]=None) -> C:
'''
Modify a dataclass to make its attributes writeable-to and readable-from JSON files
Can optionally specify additional TypeSerializers to support objects with attributes whose types are, by default, not JSON-serializable
'''
def jsonifiable_factory(cls : C) -> C:
'''Factory method, defines new class which inherits from original class and adds serialization methods'''
assert(is_dataclass(cls)) # can enforce modification only to dataclasses (makes behavior a little more natural)
multi_serializer = MultiTypeSerializer()
if type_serializer is not None:
multi_serializer.add_type_serializer(type_serializer)
# check if any of the init fields of the dataclass are also JSONifiable, register respective serializers if they are
for init_param in signature(cls).parameters.values(): # TODO: find a way to have this recognize serializable classes in default containers (e.g. list[JSONifiable])
if get_origin(init_param.annotation) == Union:
init_param_types : tuple[Type] = get_args(init_param.annotation)
else:
init_param_types : tuple[Type] = (init_param.annotation,)
for init_param_type in init_param_types: # scrape sub-TypeSerializers from annotated init argument types
if isclass(init_param_type) and issubclass(init_param_type, JSONifiable):
multi_serializer.add_type_serializer(init_param_type.serializer)
# generate serializable wrapper class
@dataclass
@wraps(
cls,
assigned=(
'__module__',
'__name__',
'__qualname__',
'__doc__',
# '__annotations__',
## DEVNOTE: NEED to have __annotations__ suppressed for wrapped functions to have correct signature on missing argument TypeError
## Before you say it, NO, "wraps" can't just be placed before (i.e. around) the @dataclass call - that's what screws up the names in the first place
),
updated=(), # DEVNOTE: set updated to empty so as to not attempt __dict__updates (classes don't have these)
) # copy over docstring, module, etc;
class WrappedClass(cls, JSONifiable):
'''Class which inherits from modified class and adds JSON serialization capability'''
serializer : ClassVar[MultiTypeSerializer] = multi_serializer
@allow_string_paths
def to_file(self, save_path : Path) -> None:
'''Store parameters in a JSON file on disc'''
assert(save_path.suffix == '.json')
with save_path.open('w') as dumpfile:
json.dump(self, dumpfile, default=self.serializer.encoder_default, indent=4)
@classmethod
@allow_string_paths
def from_file(cls, load_path : Path) -> cls:
assert(load_path.suffix == '.json')
with load_path.open('r') as loadfile:
return json.load(loadfile, object_hook=cls.serializer.decoder_hook)
# !CRITICAL! that the custom serializer be registered for WrappedClass and NOT cls; otherwise, decoded instances will have different type to the parent class
CustomSerializer = dataclass_serializer_factory(WrappedClass)
multi_serializer.add_type_serializer(CustomSerializer)
return WrappedClass
if cls is None:
return jsonifiable_factory
return jsonifiable_factory(cls)