diff --git a/CHANGELOG.md b/CHANGELOG.md index 57674776a27fea83cf3a475a3850b4545c6499fd..642983127050cfd2618bc88c49468d99aa7117b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,3 @@ # v2.25.4 + +* add JSONAbleDataObject diff --git a/docs/json.rst b/docs/json.rst index 3ef1e4858bc32a7c52bbd905ddf4ffc6102b2e82..937b50dab7755fc01c7d3d86080213324013e599 100644 --- a/docs/json.rst +++ b/docs/json.rst @@ -8,6 +8,9 @@ with .. autoclass:: satella.json.JSONAble :members: +.. autoclass:: satella.json.JSONAbleDataObject + :members: + Then you can convert structures made out of standard serializable Python JSON objects, such as dicts and lists, and also JSONAble objects, by this all diff --git a/satella/__init__.py b/satella/__init__.py index 53acef843327abced8aea829340a4656f9ea1ed8..4061a19a0e827b308381ce83b72e8ff62d827d93 100644 --- a/satella/__init__.py +++ b/satella/__init__.py @@ -1 +1 @@ -__version__ = '2.25.4a1' +__version__ = '2.25.4a2' diff --git a/satella/json.py b/satella/json.py index 073412df2b619a4fc6c6fe7f07935d9f438a5009..6c03ea5fbec5e625f87996c2abfd62a47eb69d97 100644 --- a/satella/json.py +++ b/satella/json.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import enum import json import typing as tp @@ -8,7 +10,8 @@ from satella.coding.typing import NoneType from satella.files import write_out_file_if_different __all__ = ['JSONEncoder', 'JSONAble', 'json_encode', 'read_json_from_file', - 'write_json_to_file', 'write_json_to_file_if_different'] + 'write_json_to_file', 'write_json_to_file_if_different', 'JSONAbleDataObject'] + Jsonable = tp.TypeVar('Jsonable', list, dict, str, int, float, None) @@ -119,3 +122,57 @@ def read_json_from_file(path: str) -> JSONAble: except json.decoder.JSONDecodeError as e: raise ValueError(str(e)) return v + + +class JSONAbleDataObject: + """ + A data-class that supports conversion of it's classes to JSON + + Define like this: + + >>> class CultureContext(JSONAbleDataObject): + >>> language: str + >>> timezone: str + >>> units: str = 'metric' + + Note that type annotation is mandatory and default values are supported. Being data value objects, these are + eq-able and hashable. + + And use like this: + + >>> a = CultureContext(language='pl', timezone='Europe/Warsaw') + >>> assert a.to_json() == {'language': 'pl', 'timezone': 'Europe/Warsaw', 'units': 'metric'} + >>> assert CultureContext.from_json(a.to_json) == a + """ + + def __eq__(self, other) -> bool: + return all(getattr(self, annotation) == getattr(other, annotation) for annotation in self.__class__.__annotations__.keys()) + + def __hash__(self) -> int: + hash_ = 0 + for annotation in self.__class__.__annotations__.keys(): + hash_ ^= hash(getattr(self, annotation)) + return hash_ + + def __init__(self, **kwargs): + """ + :raises ValueError: a non-default value was not provided + """ + for annotation in self.__class__.__annotations__.keys(): + try: + annot_val = kwargs.pop(annotation) + setattr(self, annotation, annot_val) + except KeyError: + if not hasattr(self, annotation): + raise ValueError(f'Argument {annotation} not provided!') + + def to_json(self) -> tp.Dict: + """Convert self to JSONable value""" + result = {} + for annotation in self.__class__.__annotations__.keys(): + result[annotation] = getattr(self, annotation) + return result + + @classmethod + def from_json(cls, jsonable) -> JSONAbleDataObject: + return cls(**jsonable) diff --git a/tests/test_coding/test_structures.py b/tests/test_coding/test_structures.py index 40526c1326d1a2b3464ae9102d106b3613601dce..f33ff62f366ed58c8be0ec054554021347b98218 100644 --- a/tests/test_coding/test_structures.py +++ b/tests/test_coding/test_structures.py @@ -15,7 +15,7 @@ from satella.coding.structures import TimeBasedHeap, Heap, typednamedtuple, \ CacheDict, StrEqHashableMixin, ComparableIntEnum, HashableIntEnum, ComparableAndHashableBy, \ ComparableAndHashableByInt, SparseMatrix, ExclusiveWritebackCache, Subqueue, \ CountingDict, ComparableEnum, LRU, LRUCacheDict, Vector, DefaultDict, PushIterable, \ - ComparableAndHashableByStr, NotEqualToAnything, NOT_EQUAL_TO_ANYTHING, DictionaryEQAble, SetZip, OnStrOnlyName + ComparableAndHashableByStr, NotEqualToAnything, NOT_EQUAL_TO_ANYTHING, DictionaryEQAble, SetZip, OnStrOnlyNameJSONAbleDataObject def continue_testing_omni(self, omni_class): diff --git a/tests/test_json.py b/tests/test_json.py index 0294141df6c8a73042ec52505593e3060578b282..2c71027d11d0bfe3d01eea23f4e81eb24a506851 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -4,11 +4,22 @@ import typing as tp import unittest from satella.json import JSONAble, json_encode, read_json_from_file, write_json_to_file, \ - write_json_to_file_if_different, JSONEncoder + write_json_to_file_if_different, JSONEncoder, JSONAbleDataObject class TestJson(unittest.TestCase): + def test_jsonable_data_object(self): + class CultureContext(JSONAbleDataObject): + units: str = 'metric' + timezone: str + language: str + + a = CultureContext(language='pl', timezone='Europe/Warsaw') + self.assertEquals(a.to_json(), {'language': 'pl', 'timezone': 'Europe/Warsaw', 'units': 'metric'}) + self.assertEquals(CultureContext.from_json(a.to_json()), a) + self.assertEquals(hash(CultureContext.from_json(a.to_json())), hash(a)) + def test_json_encoder_enums(self): enc = JSONEncoder()