Skip to content

Instantly share code, notes, and snippets.

@Sidneys1
Created October 8, 2019 14:51
Show Gist options
  • Save Sidneys1/a19c45af2c5154724f5c3d15eb3966b5 to your computer and use it in GitHub Desktop.
Save Sidneys1/a19c45af2c5154724f5c3d15eb3966b5 to your computer and use it in GitHub Desktop.
Python JsonObject
"""Helper for json-based objects"""
from inspect import isclass
from typing import get_type_hints, Union, TypeVar, Type
# cSpell:ignore isclass
def patch_target(target, patch):
if not isinstance(patch, dict):
return target
if not isinstance(target, dict):
target = {}
for key, value in patch.items():
if value is not None:
target[key] = patch_target(target.get(key), value)
elif key in target:
del target[key]
return target
TJsonObject = TypeVar('TJsonObject', bound='JsonObject')
class JsonObject:
'''Helper abstract class to convert dicts to classes and back'''
__annotations__ = {}
__READONLY__ = []
'''
A list of attributes that are treated as read-only by `patch()`
'''
def __init__(self, **kwargs):
'''Set values from kwargs'''
type_map = get_type_hints(self)
for key in kwargs:
if key[0] == "_" or not hasattr(self, key) or callable(getattr(self, key)):
raise KeyError(key)
value = JsonObject._coerce(type_map.get(key, None), kwargs[key])
setattr(self, key, value)
def to_json(self) -> dict:
'''Creates a dict from this instance'''
ret = {}
for key in [x for x in dir(self) if x[0] != '_' and not callable(getattr(self, x))]:
value = getattr(self, key)
# if value is not None:
if isinstance(value, JsonObject):
value = value.to_json()
elif isinstance(value, list):
value = [x.to_json() if isinstance(x, JsonObject) else x for x in value]
elif isinstance(value, dict):
value = JsonObject._walk_dict(value)
ret[key] = value
return ret
def patch(self, patch: dict):
for key in patch:
if not hasattr(self, key):
raise KeyError('key "{}" does not exist'.format(key))
if key in self.__READONLY__:
raise KeyError('key "{}" is read-only'.format(key))
attr = getattr(self, key)
if isinstance(attr, JsonObject):
setattr(self, key, attr.patch(patch[key]))
elif isinstance(attr, dict):
setattr(self, key, patch_target(attr, patch[key]))
else:
setattr(self, key, patch[key])
return self
@staticmethod
def _walk_dict(value: dict) -> dict:
keys = list(value.keys())
for key in keys:
new_key = key
new_value = value.pop(key)
if isinstance(key, JsonObject):
new_key = key.to_json()
if isinstance(new_value, JsonObject):
new_value = new_value.to_json()
elif isinstance(new_value, list):
new_value = [x.to_json() if isinstance(x, JsonObject) else x for x in new_value]
elif isinstance(new_value, dict):
new_value = JsonObject._walk_dict(new_value)
value[new_key] = new_value
return value
@staticmethod
def _coerce(arg_type, value):
if arg_type is None or value is None:
return value
if isclass(arg_type) and issubclass(arg_type, JsonObject):
return arg_type.from_json(value)
if getattr(arg_type, '__origin__', None) is Union:
specials = getattr(arg_type, '__args__', None)
if specials is not None and specials[1] == type(None):
# This is an Optional[], which is shorthand for Union[T, NoneType]
return JsonObject._coerce(specials[0], value)
raise NotImplementedError("JsonObject doesn't resolve unions yet")
typing_name = getattr(arg_type, '_name', None)
if typing_name == 'List':
special = getattr(arg_type, '__args__', [None])[0]
if special is not None:
return [JsonObject._coerce(special, x) for x in value]
elif typing_name == 'Dict':
special_k, special_v = getattr(arg_type, '__args__', (None, None))
value = {
JsonObject._coerce(special_k, k): JsonObject._coerce(special_v, v)
for k, v in value.items()
}
return value
@classmethod
def from_json(cls: Type[TJsonObject], json_dict: dict) -> TJsonObject:
'''
Create an instance of `cls` using values in `json_dict`.
Positional parameters become required, while everything else is passed as kwargs
'''
defaults_count = len(cls.__init__.__defaults__) if cls.__init__.__defaults__ else 0
type_map = get_type_hints(cls.__init__)
args = [
json_dict.pop(x) for x
in cls.__init__.__code__.co_varnames[1:cls.__init__.__code__.co_argcount - defaults_count]
]
for position in range(1, cls.__init__.__code__.co_argcount - defaults_count):
args[position - 1] = JsonObject._coerce(type_map.get(cls.__init__.__code__.co_varnames[position], None),
args[position - 1])
return cls(*args, **json_dict)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment