Skip to content

Commit

Permalink
feat(serialization): Add support for *args and **kwargs in to/from_di…
Browse files Browse the repository at this point in the history
…ct methods
  • Loading branch information
gMatas committed Dec 30, 2024
1 parent 2b574da commit ad008b2
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 80 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ print(obj.value == obj2.value)
# True
```

### Context managing

EzSerialization supports two context managers:
- `with no_serialization(): ...` - disables injecting class type metadata into the result of `to_dict()` method.
Leaves the result dict unfit to be deserialized automatically via `deserialize()`;
- `with use_serialization(): ...` - opposite of `no_serialization()`, enables class type metadata injection.
Useful when using inside the disabled serialization scope.

## Configuration

Currently only a single option is available for customizing `ezserialization`:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "ezserialization"
version = "0.2.11"
version = "0.3.0"
description = "Simple, easy to use & transparent python objects serialization & deserialization."
authors = ["Matas Gumbinas <[email protected]>"]
repository = "https://github.com/gMatas/ezserialization"
Expand Down
43 changes: 29 additions & 14 deletions src/ezserialization/_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def _is_serializable_subclass(cls: Type) -> bool:
:param cls: Type to check.
"""
return hasattr(cls, "from_dict") and hasattr(cls, "to_dict")
return isinstance(cls, type) and hasattr(cls, "from_dict") and hasattr(cls, "to_dict")


_thread_local = threading.local()
Expand Down Expand Up @@ -147,31 +147,46 @@ def wrapper(cls_: Type[_T]) -> Type[_T]:

def wrap_to_dict(method: Callable[..., Mapping]):
@functools.wraps(method)
def to_dict_wrapper(obj: Serializable) -> Mapping:
data = method(obj)
def to_dict_wrapper(__ctx, *__args, **__kwargs) -> Mapping:
data = method(__ctx, *__args, **__kwargs)
# Wrap object with serialization metadata.
if TYPE_FIELD_NAME in data:
raise KeyError(f"Key '{TYPE_FIELD_NAME}' already exist in the serialized data mapping!")
if _get_serialization_enabled():
typename = _typenames_[type(obj)]
return {TYPE_FIELD_NAME: typename, **data}
return copy(data)
typename = _typenames_[__ctx if isinstance(__ctx, type) else type(__ctx)]
return {TYPE_FIELD_NAME: typename, **data} # TODO: avoid copying data if possible
return copy(data) # TODO: avoid copying data if possible

return to_dict_wrapper

cls_.to_dict = wrap_to_dict(cls_.to_dict) # type: ignore[method-assign]

def wrap_from_dict(method: Callable[..., Serializable]):
@functools.wraps(method)
def from_dict_wrapper(*args) -> Serializable:
# See if `from_dict` method is staticmethod-like or classmethod-like (or normal method-like),
# i.e. `Serializable.from_dict(data)` or `Serializable().from_dict(data)`.
src = args[1] if len(args) == 2 else args[0]
# Remove deserialization metadata.
src = dict(src)
def from_dict_wrapper(*__args, **__kwargs) -> Serializable:
# Differentiate between different ways this method was called.
first_arg_type = val if isinstance(val := __args[0], type) else type(val)
if _is_same_type_by_qualname(first_arg_type, cls_):
# When this method was called as instance-method i.e. Serializable().from_dict(...)
__cls = first_arg_type
src = __args[1]
__args = __args[2:]
else:
# When this method was called as class-method i.e. Serializable.from_dict(...)
__cls = cls_
src = __args[0]
__args = __args[1:]

# Drop deserialization metadata.
src = dict(src) # TODO: avoid copying data
src.pop(TYPE_FIELD_NAME, None)
# Deserialize as-is.
return method(src)

# Deserialize.
if hasattr(method, "__self__"):
# As bounded method (class or instance method)
return method(src, *__args, **__kwargs)
# As staticmethod (simple function)
return method(__cls, src, *__args, **__kwargs)

return from_dict_wrapper

Expand Down
66 changes: 66 additions & 0 deletions tests/ezserialization_tests/test_serializable_decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import json
from typing import Mapping, cast

from ezserialization import (
Serializable,
deserialize,
serializable,
)


@serializable # <- valid for serialization
@serializable(name="A")
@serializable(name="XXX")
class _CaseAUsingAutoName(Serializable):
def __init__(self, value: str):
self.value = value

def to_raw_dict(self) -> dict:
return {"value": self.value}

def to_dict(self) -> Mapping:
return self.to_raw_dict()

@classmethod
def from_dict(cls, src: Mapping):
return cls(value=src["value"])

@classmethod
def abs_qualname(cls) -> str:
return f"{cls.__module__}.{cls.__qualname__}"


@serializable(name="B") # <- valid for serialization
@serializable(name="YYY")
@serializable
@serializable(name="ZZZ")
class _CaseBUsingNameAlias(Serializable):
def __init__(self, value: str):
self.value = value

def to_dict(self) -> Mapping:
return {"value": self.value}

@classmethod
def from_dict(cls, src: Mapping):
return cls(value=src["value"])


def test_serialization_typenames_order():
"""
Expected behaviour: Only the top typename is used to serialize instances.
On the other hand, for deserialization all typenames are valid.
"""

a = _CaseAUsingAutoName("a")
data = a.to_dict()

a.from_dict(data)

assert data["_type_"] == _CaseAUsingAutoName.abs_qualname()
assert a.value == cast(_CaseAUsingAutoName, deserialize(json.loads(json.dumps(data)))).value

b = _CaseBUsingNameAlias("b")
data = b.to_dict()
assert data["_type_"] == "B"
assert b.value == cast(_CaseBUsingNameAlias, deserialize(json.loads(json.dumps(data)))).value
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
import threading
import time
from typing import Mapping, cast
from typing import Mapping

from ezserialization import (
Serializable,
deserialize,
no_serialization,
serializable,
use_serialization,
using_serialization,
)


@serializable # <- valid for serialization
@serializable(name="A")
@serializable(name="XXX")
class _CaseAUsingAutoName(Serializable):
@serializable
class _TestSerializable(Serializable):
def __init__(self, value: str):
self.value = value

Expand All @@ -29,46 +26,33 @@ def to_dict(self) -> Mapping:
def from_dict(cls, src: Mapping):
return cls(value=src["value"])

@classmethod
def abs_qualname(cls) -> str:
return f"{cls.__module__}.{cls.__qualname__}"


@serializable(name="B") # <- valid for serialization
@serializable(name="YYY")
@serializable
@serializable(name="ZZZ")
class _CaseBUsingNameAlias(Serializable):
def __init__(self, value: str):
self.value = value

def to_dict(self) -> Mapping:
return {"value": self.value}

@classmethod
def from_dict(cls, src: Mapping):
return cls(value=src["value"])


def test_serialization_typenames_order():
"""
Expected behaviour: Only the top typename is used to serialize instances.
On the other hand, for deserialization all typenames are valid.
"""

a = _CaseAUsingAutoName("a")
data = a.to_dict()
assert data["_type_"] == _CaseAUsingAutoName.abs_qualname()
assert a.value == cast(_CaseAUsingAutoName, deserialize(data)).value
class _TestThread(threading.Thread):
def __init__(self):
self.exception = None
self.finished = False
self.should_stop = False
self.serialization_explicitly_enabled = False
super().__init__(target=self._fun, daemon=True)

b = _CaseBUsingNameAlias("b")
data = b.to_dict()
assert data["_type_"] == "B"
assert b.value == cast(_CaseBUsingNameAlias, deserialize(data)).value
def _fun(self):
try:
assert using_serialization()
with use_serialization():
assert using_serialization()
self.serialization_explicitly_enabled = True
while not self.should_stop:
time.sleep(0.1)
except Exception as e:
self.exception = e
finally:
self.finished = True
self.should_stop = True
self.serialization_explicitly_enabled = True


def test_threadsafe_serialization_enabling_and_disabling():
a = _CaseAUsingAutoName("foo")
a = _TestSerializable("foo")

assert using_serialization(), "By default, serialization must be enabled!"

Expand Down Expand Up @@ -97,27 +81,3 @@ def test_threadsafe_serialization_enabling_and_disabling():
raise thread.exception

assert using_serialization()


class _TestThread(threading.Thread):
def __init__(self):
self.exception = None
self.finished = False
self.should_stop = False
self.serialization_explicitly_enabled = False
super().__init__(target=self._fun, daemon=True)

def _fun(self):
try:
assert using_serialization()
with use_serialization():
assert using_serialization()
self.serialization_explicitly_enabled = True
while not self.should_stop:
time.sleep(0.1)
except Exception as e:
self.exception = e
finally:
self.finished = True
self.should_stop = True
self.serialization_explicitly_enabled = True
63 changes: 63 additions & 0 deletions tests/ezserialization_tests/test_to_and_from_dict_methods.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import json
from abc import ABC
from typing import Mapping, Type, cast, overload

from ezserialization import (
Serializable,
deserialize,
serializable,
)


class _BaseTestCase(Serializable, ABC):
def __init__(self, value: str):
self.value = value

def to_dict(self) -> Mapping:
return self.to_raw_dict()

def to_raw_dict(self) -> dict:
return {"value": self.value}


@serializable
class _TestFromDictWithClassmethod(_BaseTestCase):
@classmethod
def from_dict(cls, src: Mapping) -> "_TestFromDictWithClassmethod":
return cls(value=src["value"])


@serializable
class _TestFromDictWithStaticmethod(_BaseTestCase):
@staticmethod
@overload
def from_dict(cls: Type["_TestFromDictWithStaticmethod"], src: Mapping) -> "_TestFromDictWithStaticmethod": ...

@staticmethod
@overload
def from_dict(*args) -> "_TestFromDictWithStaticmethod": ...

@staticmethod
def from_dict(*args, **kwargs) -> "_TestFromDictWithStaticmethod":
obj = args[0](value=args[1]["value"])
assert isinstance(obj, _TestFromDictWithStaticmethod)
return obj


def test_from_dict_as_classmethod():
obj = _TestFromDictWithClassmethod("wow")
obj_dict = obj.to_dict()
assert obj.value == obj.from_dict(obj_dict).value
assert obj.value == _TestFromDictWithClassmethod.from_dict(obj_dict).value

assert obj.value == cast(_TestFromDictWithClassmethod, deserialize(json.loads(json.dumps(obj_dict)))).value


def test_from_dict_as_staticmethod():
obj = _TestFromDictWithStaticmethod("wow")
obj_dict = obj.to_dict()
assert obj.value == obj.from_dict(obj_dict).value
assert obj.value == _TestFromDictWithStaticmethod.from_dict(obj, obj_dict).value
assert obj.value == _TestFromDictWithStaticmethod.from_dict(_TestFromDictWithStaticmethod, obj_dict).value

assert obj.value == cast(_TestFromDictWithStaticmethod, deserialize(json.loads(json.dumps(obj_dict)))).value

0 comments on commit ad008b2

Please sign in to comment.