From c35bf0427b689eefdc388223380e379127785cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ma=C5=9Blanka?= <piotr.maslanka@ericsson.com> Date: Fri, 19 Apr 2024 08:04:39 +0200 Subject: [PATCH] add JSONAbleDataObject --- CHANGELOG.md | 2 + docs/json.rst | 3 ++ satella/__init__.py | 2 +- satella/json.py | 59 +++++++++++++++++++++++++++- tests/test_coding/test_structures.py | 2 +- tests/test_json.py | 13 +++++- 6 files changed, 77 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57674776..64298312 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 3ef1e485..937b50da 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 53acef84..4061a19a 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 073412df..6c03ea5f 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 40526c13..f33ff62f 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 0294141d..2c71027d 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() -- GitLab