serializable 1.1.2__tar.gz → 1.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {serializable-1.1.2 → serializable-1.2.1}/PKG-INFO +10 -2
- {serializable-1.1.2 → serializable-1.2.1}/README.md +5 -1
- {serializable-1.1.2 → serializable-1.2.1}/pyproject.toml +13 -0
- {serializable-1.1.2 → serializable-1.2.1}/serializable/dataclass_serializable.py +15 -9
- {serializable-1.1.2 → serializable-1.2.1}/serializable/helpers.py +81 -48
- serializable-1.2.1/serializable/py.typed +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/serializable/serializable.py +35 -32
- {serializable-1.1.2 → serializable-1.2.1}/serializable/version.py +1 -1
- {serializable-1.1.2 → serializable-1.2.1}/serializable.egg-info/PKG-INFO +10 -2
- {serializable-1.1.2 → serializable-1.2.1}/serializable.egg-info/SOURCES.txt +4 -1
- {serializable-1.1.2 → serializable-1.2.1}/serializable.egg-info/requires.txt +1 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_default_to_dict_implementation.py +14 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_keyword_aliases.py +7 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_nesting.py +34 -1
- serializable-1.2.1/tests/test_pickle.py +102 -0
- serializable-1.2.1/tests/test_typing.py +48 -0
- {serializable-1.1.2 → serializable-1.2.1}/LICENSE +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/serializable/__init__.py +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/serializable/primitive_types.py +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/serializable.egg-info/dependency_links.txt +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/serializable.egg-info/top_level.txt +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/setup.cfg +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/setup.py +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_class.py +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_dataclass_serializable.py +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_function.py +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_namedtuple.py +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_object.py +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_primitive_values.py +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_set.py +0 -0
- {serializable-1.1.2 → serializable-1.2.1}/tests/test_tuple.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: serializable
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: Base class with serialization helpers for user-defined Python objects
|
|
5
5
|
Author-email: Alex Rubinsteyn <alex.rubinsteyn@unc.edu>
|
|
6
6
|
License: Apache License
|
|
@@ -217,6 +217,9 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
217
217
|
Classifier: Programming Language :: Python :: 3.10
|
|
218
218
|
Classifier: Programming Language :: Python :: 3.11
|
|
219
219
|
Classifier: Programming Language :: Python :: 3.12
|
|
220
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
221
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
222
|
+
Classifier: Typing :: Typed
|
|
220
223
|
Requires-Python: >=3.9
|
|
221
224
|
Description-Content-Type: text/markdown
|
|
222
225
|
License-File: LICENSE
|
|
@@ -226,6 +229,7 @@ Provides-Extra: dev
|
|
|
226
229
|
Requires-Dist: pytest; extra == "dev"
|
|
227
230
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
228
231
|
Requires-Dist: ruff; extra == "dev"
|
|
232
|
+
Requires-Dist: mypy; extra == "dev"
|
|
229
233
|
Requires-Dist: coveralls; extra == "dev"
|
|
230
234
|
Requires-Dist: build; extra == "dev"
|
|
231
235
|
Requires-Dist: twine; extra == "dev"
|
|
@@ -274,9 +278,13 @@ helpers.
|
|
|
274
278
|
|
|
275
279
|
A derived class must either:
|
|
276
280
|
|
|
277
|
-
- have a member data matching the name of each argument to `__init__`
|
|
281
|
+
- have a member data matching the name of each positional argument to `__init__`
|
|
278
282
|
- provide a user-defined `to_dict()` method which returns a dictionary whose keys match the arguments to `__init__`
|
|
279
283
|
|
|
284
|
+
Keyword-only arguments to `__init__` (those after `*`) are left out of the default `to_dict()`, so they can be used for constructor options which aren't part of an object's serialized state.
|
|
285
|
+
|
|
286
|
+
Pickling goes through the same `to_dict()` / `from_dict()` pair, so pickles also survive renamed keywords (see below). Pickles written by versions before 1.2.0 can still be loaded.
|
|
287
|
+
|
|
280
288
|
If you change the keyword arguments to a class which derives from `Serializable` but would like to be able to deserialize older JSON representations then you can define a class-level dictionary called `_SERIALIZABLE_KEYWORD_ALIASES` which maps old keywords to new names (or `None` if a keyword was removed).
|
|
281
289
|
|
|
282
290
|
## Limitations
|
|
@@ -41,9 +41,13 @@ helpers.
|
|
|
41
41
|
|
|
42
42
|
A derived class must either:
|
|
43
43
|
|
|
44
|
-
- have a member data matching the name of each argument to `__init__`
|
|
44
|
+
- have a member data matching the name of each positional argument to `__init__`
|
|
45
45
|
- provide a user-defined `to_dict()` method which returns a dictionary whose keys match the arguments to `__init__`
|
|
46
46
|
|
|
47
|
+
Keyword-only arguments to `__init__` (those after `*`) are left out of the default `to_dict()`, so they can be used for constructor options which aren't part of an object's serialized state.
|
|
48
|
+
|
|
49
|
+
Pickling goes through the same `to_dict()` / `from_dict()` pair, so pickles also survive renamed keywords (see below). Pickles written by versions before 1.2.0 can still be loaded.
|
|
50
|
+
|
|
47
51
|
If you change the keyword arguments to a class which derives from `Serializable` but would like to be able to deserialize older JSON representations then you can define a class-level dictionary called `_SERIALIZABLE_KEYWORD_ALIASES` which maps old keywords to new names (or `None` if a keyword was removed).
|
|
48
52
|
|
|
49
53
|
## Limitations
|
|
@@ -22,6 +22,9 @@ classifiers = [
|
|
|
22
22
|
"Programming Language :: Python :: 3.10",
|
|
23
23
|
"Programming Language :: Python :: 3.11",
|
|
24
24
|
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Typing :: Typed",
|
|
25
28
|
]
|
|
26
29
|
requires-python = ">=3.9"
|
|
27
30
|
dependencies = [
|
|
@@ -34,6 +37,7 @@ dev = [
|
|
|
34
37
|
"pytest",
|
|
35
38
|
"pytest-cov",
|
|
36
39
|
"ruff",
|
|
40
|
+
"mypy",
|
|
37
41
|
"coveralls",
|
|
38
42
|
"build",
|
|
39
43
|
"twine",
|
|
@@ -49,6 +53,9 @@ version = {attr = "serializable.version.__version__"}
|
|
|
49
53
|
[tool.setuptools.packages.find]
|
|
50
54
|
include = ["serializable*"]
|
|
51
55
|
|
|
56
|
+
[tool.setuptools.package-data]
|
|
57
|
+
serializable = ["py.typed"]
|
|
58
|
+
|
|
52
59
|
[tool.ruff]
|
|
53
60
|
target-version = "py39"
|
|
54
61
|
line-length = 100
|
|
@@ -81,6 +88,7 @@ ignore = [
|
|
|
81
88
|
"B905", # zip() without explicit strict
|
|
82
89
|
"SIM108", # use ternary operator instead of if-else
|
|
83
90
|
"UP007", # use X | Y for type unions (need Python 3.10+)
|
|
91
|
+
"UP045", # use X | None for Optional (need Python 3.10+)
|
|
84
92
|
]
|
|
85
93
|
|
|
86
94
|
[tool.ruff.lint.per-file-ignores]
|
|
@@ -96,6 +104,11 @@ ignore = [
|
|
|
96
104
|
[tool.ruff.lint.isort]
|
|
97
105
|
known-first-party = ["serializable"]
|
|
98
106
|
|
|
107
|
+
[tool.mypy]
|
|
108
|
+
# Annotations are never evaluated at runtime (`from __future__ import annotations`),
|
|
109
|
+
# so checking them as 3.10 also covers 3.9, which mypy 2 can no longer target.
|
|
110
|
+
python_version = "3.10"
|
|
111
|
+
|
|
99
112
|
[tool.pytest.ini_options]
|
|
100
113
|
testpaths = ["tests"]
|
|
101
114
|
python_files = "test_*.py"
|
|
@@ -40,10 +40,13 @@ Example::
|
|
|
40
40
|
|
|
41
41
|
from __future__ import annotations
|
|
42
42
|
|
|
43
|
+
from collections.abc import Mapping
|
|
43
44
|
from dataclasses import fields
|
|
44
|
-
from typing import Any, ClassVar
|
|
45
|
+
from typing import Any, ClassVar, Optional, TypeVar
|
|
45
46
|
|
|
46
|
-
from .helpers import from_json,
|
|
47
|
+
from .helpers import from_json, to_json
|
|
48
|
+
|
|
49
|
+
_T = TypeVar("_T", bound="DataclassSerializable")
|
|
47
50
|
|
|
48
51
|
|
|
49
52
|
class DataclassSerializable:
|
|
@@ -57,16 +60,17 @@ class DataclassSerializable:
|
|
|
57
60
|
``Serializable``.
|
|
58
61
|
"""
|
|
59
62
|
|
|
60
|
-
_SERIALIZABLE_KEYWORD_ALIASES: ClassVar[dict[str, str
|
|
63
|
+
_SERIALIZABLE_KEYWORD_ALIASES: ClassVar[dict[str, Optional[str]]] = {}
|
|
61
64
|
|
|
62
65
|
def to_dict(self) -> dict[str, Any]:
|
|
63
66
|
"""Return a dict mapping each dataclass field name to its current
|
|
64
67
|
value. Keys match the ``__init__`` keyword arguments, so
|
|
65
68
|
``cls(**obj.to_dict())`` reconstructs an equal instance."""
|
|
66
|
-
|
|
69
|
+
# subclasses are dataclasses, the mixin itself isn't
|
|
70
|
+
return {f.name: getattr(self, f.name) for f in fields(self)} # type: ignore[arg-type]
|
|
67
71
|
|
|
68
72
|
@classmethod
|
|
69
|
-
def from_dict(cls, state_dict:
|
|
73
|
+
def from_dict(cls: type[_T], state_dict: Mapping[str, Any]) -> _T:
|
|
70
74
|
"""Reconstruct an instance from a ``to_dict``-shaped dictionary,
|
|
71
75
|
applying ``_SERIALIZABLE_KEYWORD_ALIASES`` for backwards compat."""
|
|
72
76
|
kwargs = dict(state_dict)
|
|
@@ -83,11 +87,13 @@ class DataclassSerializable:
|
|
|
83
87
|
return to_json(self)
|
|
84
88
|
|
|
85
89
|
@classmethod
|
|
86
|
-
def from_json(cls, json_string: str):
|
|
90
|
+
def from_json(cls: type[_T], json_string: str) -> _T:
|
|
87
91
|
return from_json(json_string)
|
|
88
92
|
|
|
89
|
-
def __reduce__(self):
|
|
93
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
90
94
|
"""Pickle via the same to_dict / from_dict path used for JSON so
|
|
91
95
|
pickled objects round-trip even when field order or internal
|
|
92
|
-
representation changes between releases.
|
|
93
|
-
|
|
96
|
+
representation changes between releases. Pickles written before
|
|
97
|
+
serializable 1.2.0 call from_serializable_repr instead, which remains
|
|
98
|
+
supported."""
|
|
99
|
+
return (type(self).from_dict, (self.to_dict(),))
|
|
@@ -15,39 +15,49 @@ Helper functions for deconstructing classes, functions, and user-defined
|
|
|
15
15
|
objects into serializable types.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from functools import lru_cache
|
|
18
21
|
from types import BuiltinFunctionType, FunctionType
|
|
22
|
+
from typing import Any, Union
|
|
19
23
|
|
|
20
|
-
import simplejson as json
|
|
24
|
+
import simplejson as json # type: ignore[import-untyped]
|
|
21
25
|
|
|
22
|
-
from .primitive_types import
|
|
26
|
+
from .primitive_types import PRIMITIVE_TYPES
|
|
23
27
|
|
|
24
28
|
|
|
25
|
-
def init_arg_names(obj):
|
|
29
|
+
def init_arg_names(obj: Any) -> tuple[str, ...]:
|
|
26
30
|
"""
|
|
27
|
-
Names of arguments to __init__ method of this
|
|
31
|
+
Names of the positional arguments to the __init__ method of this
|
|
32
|
+
object's class (excluding self).
|
|
33
|
+
|
|
34
|
+
Keyword-only arguments are deliberately excluded, so a class can accept
|
|
35
|
+
constructor options which aren't part of its serialized state.
|
|
28
36
|
"""
|
|
37
|
+
# mypy doesn't consider classes Hashable
|
|
38
|
+
return _class_init_arg_names(type(obj)) # type: ignore[arg-type]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Cached per class since this is called on every to_dict/__eq__/__hash__ of
|
|
42
|
+
# a Serializable which doesn't override to_dict. Assumes a class's __init__
|
|
43
|
+
# isn't replaced after its first instance has been serialized.
|
|
44
|
+
@lru_cache(maxsize=1024)
|
|
45
|
+
def _class_init_arg_names(cls):
|
|
29
46
|
# doing something wildly hacky by pulling out the arguments to
|
|
30
|
-
# __init__
|
|
31
|
-
# object
|
|
47
|
+
# __init__ and hoping that they match fields defined on the object
|
|
32
48
|
try:
|
|
33
|
-
init_code =
|
|
34
|
-
except AttributeError:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if hasattr(obj, "_fields"):
|
|
41
|
-
return obj._fields
|
|
42
|
-
raise ValueError(f"Cannot determine args to {obj}.__init__") from exc
|
|
43
|
-
|
|
44
|
-
arg_names = init_code.co_varnames[: init_code.co_argcount]
|
|
49
|
+
init_code = cls.__init__.__code__
|
|
50
|
+
except AttributeError as exc:
|
|
51
|
+
# if object is a namedtuple then we can return its fields
|
|
52
|
+
# as the required initial args
|
|
53
|
+
if hasattr(cls, "_fields"):
|
|
54
|
+
return cls._fields
|
|
55
|
+
raise ValueError(f"Cannot determine args to {cls.__qualname__}.__init__") from exc
|
|
45
56
|
# drop self argument
|
|
46
|
-
|
|
47
|
-
return nonself_arg_names
|
|
57
|
+
return init_code.co_varnames[1 : init_code.co_argcount]
|
|
48
58
|
|
|
49
59
|
|
|
50
|
-
def simple_object_to_dict(self):
|
|
60
|
+
def simple_object_to_dict(self: object) -> dict[str, Any]:
|
|
51
61
|
return {name: getattr(self, name) for name in init_arg_names(self)}
|
|
52
62
|
|
|
53
63
|
|
|
@@ -185,10 +195,12 @@ def dict_to_serializable_repr(x):
|
|
|
185
195
|
return result
|
|
186
196
|
|
|
187
197
|
|
|
188
|
-
def
|
|
198
|
+
def _from_reconstructed_dict(x):
|
|
189
199
|
"""
|
|
190
|
-
|
|
191
|
-
|
|
200
|
+
Given a dictionary from a serializable representation whose values have
|
|
201
|
+
already been reconstructed, return the object it represents: a class or
|
|
202
|
+
function, an instance of a user-defined class, or a dictionary (restoring
|
|
203
|
+
any non-string keys). May modify `x`.
|
|
192
204
|
|
|
193
205
|
This is the most hackish part since we rely on key names such as
|
|
194
206
|
__name__, __class__, __module__ as metadata about how to reconstruct
|
|
@@ -200,33 +212,45 @@ def from_serializable_dict(x):
|
|
|
200
212
|
flattened result of to_dict() for user-defined objects.
|
|
201
213
|
"""
|
|
202
214
|
if "__name__" in x:
|
|
203
|
-
return _lookup_value(x
|
|
215
|
+
return _lookup_value(x["__module__"], x["__name__"])
|
|
216
|
+
|
|
217
|
+
serialized_keys = x.pop(SERIALIZED_DICTIONARY_KEYS_FIELD, None)
|
|
218
|
+
if serialized_keys:
|
|
219
|
+
non_string_key_objects = [from_json(serialized_key) for serialized_key in serialized_keys]
|
|
220
|
+
converted_dict = type(x)()
|
|
221
|
+
for k, v in x.items():
|
|
222
|
+
serialized_key_index = parse_serialized_keys_index(k)
|
|
223
|
+
if serialized_key_index is not None:
|
|
224
|
+
k = non_string_key_objects[serialized_key_index]
|
|
225
|
+
converted_dict[k] = v
|
|
226
|
+
x = converted_dict
|
|
227
|
+
|
|
228
|
+
if "__class__" not in x:
|
|
229
|
+
return x
|
|
230
|
+
class_object = x.pop("__class__")
|
|
231
|
+
if "__value__" in x:
|
|
232
|
+
return class_object(x["__value__"])
|
|
233
|
+
if hasattr(class_object, "from_dict"):
|
|
234
|
+
return class_object.from_dict(x)
|
|
235
|
+
return class_object(**x)
|
|
204
236
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
237
|
+
|
|
238
|
+
def from_serializable_dict(x):
|
|
239
|
+
"""
|
|
240
|
+
Reconstruct a dictionary, or the object it represents, by recursively
|
|
241
|
+
reconstructing all its keys and values. Does not modify `x`.
|
|
242
|
+
"""
|
|
208
243
|
converted_dict = type(x)()
|
|
209
244
|
for k, v in x.items():
|
|
210
|
-
serialized_key_index = parse_serialized_keys_index(k)
|
|
211
|
-
if serialized_key_index is not None:
|
|
212
|
-
k = non_string_key_objects[serialized_key_index]
|
|
213
|
-
|
|
214
245
|
converted_dict[k] = from_serializable_repr(v)
|
|
215
|
-
|
|
216
|
-
class_object = converted_dict.pop("__class__")
|
|
217
|
-
if "__value__" in converted_dict:
|
|
218
|
-
return class_object(converted_dict["__value__"])
|
|
219
|
-
if hasattr(class_object, "from_dict"):
|
|
220
|
-
return class_object.from_dict(converted_dict)
|
|
221
|
-
return class_object(**converted_dict)
|
|
222
|
-
return converted_dict
|
|
246
|
+
return _from_reconstructed_dict(converted_dict)
|
|
223
247
|
|
|
224
248
|
|
|
225
249
|
def list_to_serializable_repr(x):
|
|
226
250
|
return [to_serializable_repr(element) for element in x]
|
|
227
251
|
|
|
228
252
|
|
|
229
|
-
def to_dict(obj):
|
|
253
|
+
def to_dict(obj: Any) -> dict[str, Any]:
|
|
230
254
|
"""
|
|
231
255
|
If value isn't a primitive scalar or collection then it needs to
|
|
232
256
|
either implement to_dict (instances of Serializable) or have member
|
|
@@ -242,12 +266,13 @@ def to_dict(obj):
|
|
|
242
266
|
raise ValueError(f"Cannot convert {obj} : {type(obj)} to dictionary") from exc
|
|
243
267
|
|
|
244
268
|
|
|
245
|
-
|
|
246
|
-
def to_serializable_repr(x):
|
|
269
|
+
def to_serializable_repr(x: Any) -> Any:
|
|
247
270
|
"""
|
|
248
271
|
Convert an instance of Serializable or a primitive collection containing
|
|
249
272
|
such instances into serializable types.
|
|
250
273
|
"""
|
|
274
|
+
if isinstance(x, PRIMITIVE_TYPES):
|
|
275
|
+
return x
|
|
251
276
|
t = type(x)
|
|
252
277
|
if isinstance(x, list):
|
|
253
278
|
return list_to_serializable_repr(x)
|
|
@@ -267,8 +292,12 @@ def to_serializable_repr(x):
|
|
|
267
292
|
return state_dictionary
|
|
268
293
|
|
|
269
294
|
|
|
270
|
-
|
|
271
|
-
|
|
295
|
+
def from_serializable_repr(x: Any) -> Any:
|
|
296
|
+
"""
|
|
297
|
+
Inverse of to_serializable_repr. Does not modify `x`.
|
|
298
|
+
"""
|
|
299
|
+
if isinstance(x, PRIMITIVE_TYPES):
|
|
300
|
+
return x
|
|
272
301
|
t = type(x)
|
|
273
302
|
if isinstance(x, list):
|
|
274
303
|
return t([from_serializable_repr(element) for element in x])
|
|
@@ -277,7 +306,7 @@ def from_serializable_repr(x):
|
|
|
277
306
|
raise TypeError(f"Cannot convert {x} : {type(x)} from serializable representation to object")
|
|
278
307
|
|
|
279
308
|
|
|
280
|
-
def to_json(x):
|
|
309
|
+
def to_json(x: Any) -> str:
|
|
281
310
|
"""
|
|
282
311
|
Returns JSON representation of a given Serializable instance or
|
|
283
312
|
other primitive object.
|
|
@@ -285,5 +314,9 @@ def to_json(x):
|
|
|
285
314
|
return json.dumps(to_serializable_repr(x))
|
|
286
315
|
|
|
287
316
|
|
|
288
|
-
def from_json(json_string):
|
|
289
|
-
|
|
317
|
+
def from_json(json_string: Union[str, bytes]) -> Any:
|
|
318
|
+
"""
|
|
319
|
+
Inverse of to_json. Objects are reconstructed bottom-up while parsing,
|
|
320
|
+
equivalent to (but faster than) from_serializable_repr(json.loads(...)).
|
|
321
|
+
"""
|
|
322
|
+
return json.loads(json_string, object_hook=_from_reconstructed_dict)
|
|
File without changes
|
|
@@ -11,15 +11,15 @@
|
|
|
11
11
|
# limitations under the License.
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
from
|
|
14
|
+
from __future__ import annotations
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
)
|
|
16
|
+
import os
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any, ClassVar, Optional, TypeVar, Union
|
|
19
|
+
|
|
20
|
+
from .helpers import from_json, simple_object_to_dict, to_json
|
|
21
|
+
|
|
22
|
+
_T = TypeVar("_T", bound="Serializable")
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class Serializable:
|
|
@@ -30,17 +30,17 @@ class Serializable:
|
|
|
30
30
|
Relies on the keys of to_dict() matching the arguments to __init__.
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
|
-
def __str__(self):
|
|
33
|
+
def __str__(self) -> str:
|
|
34
34
|
fields = ", ".join(f"{k}={v}" for (k, v) in self.to_dict().items())
|
|
35
35
|
return f"{self.__class__.__name__}({fields})"
|
|
36
36
|
|
|
37
|
-
def __repr__(self):
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
38
|
return str(self)
|
|
39
39
|
|
|
40
|
-
def __eq__(self, other):
|
|
40
|
+
def __eq__(self, other) -> bool:
|
|
41
41
|
return self.__class__ is other.__class__ and self.to_dict() == other.to_dict()
|
|
42
42
|
|
|
43
|
-
def to_dict(self):
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
44
|
"""
|
|
45
45
|
Returns a dictionary which can be used to reconstruct an instance
|
|
46
46
|
of a derived class (typically by matching args to __init__). The values
|
|
@@ -48,16 +48,18 @@ class Serializable:
|
|
|
48
48
|
(bool, string, int, float), primitive collections
|
|
49
49
|
(int, list, tuple, set) or instances of Serializable.
|
|
50
50
|
|
|
51
|
-
The default implementation is to assume all the arguments
|
|
52
|
-
have fields of the same name on a serializable object.
|
|
51
|
+
The default implementation is to assume all the positional arguments
|
|
52
|
+
to __init__ have fields of the same name on a serializable object.
|
|
53
|
+
Keyword-only arguments are excluded, so they can be used for
|
|
54
|
+
constructor options which aren't part of the serialized state.
|
|
53
55
|
"""
|
|
54
56
|
return simple_object_to_dict(self)
|
|
55
57
|
|
|
56
|
-
def __hash__(self):
|
|
58
|
+
def __hash__(self) -> int:
|
|
57
59
|
return hash(tuple(sorted(self.to_dict().items())))
|
|
58
60
|
|
|
59
61
|
@classmethod
|
|
60
|
-
def _reconstruct_nested_objects(cls, state_dict):
|
|
62
|
+
def _reconstruct_nested_objects(cls, state_dict: dict[str, Any]) -> dict[str, Any]:
|
|
61
63
|
"""
|
|
62
64
|
Nested serializable objects will be represented as dictionaries so we
|
|
63
65
|
allow manual reconstruction of those objects in this method.
|
|
@@ -68,10 +70,10 @@ class Serializable:
|
|
|
68
70
|
|
|
69
71
|
# dictionary mapping old keywords to either new names or
|
|
70
72
|
# None if the keyword has been removed from a class
|
|
71
|
-
_SERIALIZABLE_KEYWORD_ALIASES: ClassVar[dict] = {}
|
|
73
|
+
_SERIALIZABLE_KEYWORD_ALIASES: ClassVar[dict[str, Optional[str]]] = {}
|
|
72
74
|
|
|
73
75
|
@classmethod
|
|
74
|
-
def _update_kwargs(cls, kwargs):
|
|
76
|
+
def _update_kwargs(cls, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
75
77
|
"""
|
|
76
78
|
Rename any old keyword arguments to preserve backwards compatibility
|
|
77
79
|
"""
|
|
@@ -87,28 +89,28 @@ class Serializable:
|
|
|
87
89
|
return kwargs
|
|
88
90
|
|
|
89
91
|
@classmethod
|
|
90
|
-
def from_dict(cls, state_dict):
|
|
92
|
+
def from_dict(cls: type[_T], state_dict: Mapping[str, Any]) -> _T:
|
|
91
93
|
"""
|
|
92
94
|
Given a dictionary of flattened fields (result of calling to_dict()),
|
|
93
|
-
returns an instance.
|
|
95
|
+
returns an instance. Does not modify `state_dict`.
|
|
94
96
|
"""
|
|
95
|
-
state_dict = cls._reconstruct_nested_objects(state_dict)
|
|
97
|
+
state_dict = cls._reconstruct_nested_objects(dict(state_dict))
|
|
96
98
|
return cls(**cls._update_kwargs(state_dict))
|
|
97
99
|
|
|
98
|
-
def to_json(self):
|
|
100
|
+
def to_json(self) -> str:
|
|
99
101
|
"""
|
|
100
102
|
Returns a string containing a JSON representation of this object.
|
|
101
103
|
"""
|
|
102
104
|
return to_json(self)
|
|
103
105
|
|
|
104
106
|
@classmethod
|
|
105
|
-
def from_json(cls, json_string):
|
|
107
|
+
def from_json(cls: type[_T], json_string: str) -> _T:
|
|
106
108
|
"""
|
|
107
109
|
Reconstruct an instance from a JSON string.
|
|
108
110
|
"""
|
|
109
111
|
return from_json(json_string)
|
|
110
112
|
|
|
111
|
-
def write_json_file(self, path):
|
|
113
|
+
def write_json_file(self, path: Union[str, os.PathLike[str]]) -> None:
|
|
112
114
|
"""
|
|
113
115
|
Serialize this object to JSON and write it to the text file at `path`.
|
|
114
116
|
"""
|
|
@@ -116,7 +118,7 @@ class Serializable:
|
|
|
116
118
|
f.write(self.to_json())
|
|
117
119
|
|
|
118
120
|
@classmethod
|
|
119
|
-
def read_json_file(cls, path):
|
|
121
|
+
def read_json_file(cls: type[_T], path: Union[str, os.PathLike[str]]) -> _T:
|
|
120
122
|
"""
|
|
121
123
|
Construct an instance of this class from the JSON file at `path`.
|
|
122
124
|
"""
|
|
@@ -124,13 +126,14 @@ class Serializable:
|
|
|
124
126
|
json_string = f.read()
|
|
125
127
|
return cls.from_json(json_string)
|
|
126
128
|
|
|
127
|
-
def __reduce__(self):
|
|
129
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
128
130
|
"""
|
|
129
131
|
Overriding this method directs the default pickler to reconstruct
|
|
130
|
-
this object using our from_dict method
|
|
131
|
-
|
|
132
|
+
this object using our from_dict method, so pickles survive the same
|
|
133
|
+
changes to a class (e.g. renamed keywords) as JSON does. Values in
|
|
134
|
+
to_dict() are pickled natively.
|
|
132
135
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return (
|
|
136
|
+
Pickles written before serializable 1.2.0 call from_serializable_repr
|
|
137
|
+
instead, which remains supported.
|
|
138
|
+
"""
|
|
139
|
+
return (type(self).from_dict, (self.to_dict(),))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: serializable
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: Base class with serialization helpers for user-defined Python objects
|
|
5
5
|
Author-email: Alex Rubinsteyn <alex.rubinsteyn@unc.edu>
|
|
6
6
|
License: Apache License
|
|
@@ -217,6 +217,9 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
217
217
|
Classifier: Programming Language :: Python :: 3.10
|
|
218
218
|
Classifier: Programming Language :: Python :: 3.11
|
|
219
219
|
Classifier: Programming Language :: Python :: 3.12
|
|
220
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
221
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
222
|
+
Classifier: Typing :: Typed
|
|
220
223
|
Requires-Python: >=3.9
|
|
221
224
|
Description-Content-Type: text/markdown
|
|
222
225
|
License-File: LICENSE
|
|
@@ -226,6 +229,7 @@ Provides-Extra: dev
|
|
|
226
229
|
Requires-Dist: pytest; extra == "dev"
|
|
227
230
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
228
231
|
Requires-Dist: ruff; extra == "dev"
|
|
232
|
+
Requires-Dist: mypy; extra == "dev"
|
|
229
233
|
Requires-Dist: coveralls; extra == "dev"
|
|
230
234
|
Requires-Dist: build; extra == "dev"
|
|
231
235
|
Requires-Dist: twine; extra == "dev"
|
|
@@ -274,9 +278,13 @@ helpers.
|
|
|
274
278
|
|
|
275
279
|
A derived class must either:
|
|
276
280
|
|
|
277
|
-
- have a member data matching the name of each argument to `__init__`
|
|
281
|
+
- have a member data matching the name of each positional argument to `__init__`
|
|
278
282
|
- provide a user-defined `to_dict()` method which returns a dictionary whose keys match the arguments to `__init__`
|
|
279
283
|
|
|
284
|
+
Keyword-only arguments to `__init__` (those after `*`) are left out of the default `to_dict()`, so they can be used for constructor options which aren't part of an object's serialized state.
|
|
285
|
+
|
|
286
|
+
Pickling goes through the same `to_dict()` / `from_dict()` pair, so pickles also survive renamed keywords (see below). Pickles written by versions before 1.2.0 can still be loaded.
|
|
287
|
+
|
|
280
288
|
If you change the keyword arguments to a class which derives from `Serializable` but would like to be able to deserialize older JSON representations then you can define a class-level dictionary called `_SERIALIZABLE_KEYWORD_ALIASES` which maps old keywords to new names (or `None` if a keyword was removed).
|
|
281
289
|
|
|
282
290
|
## Limitations
|
|
@@ -6,6 +6,7 @@ serializable/__init__.py
|
|
|
6
6
|
serializable/dataclass_serializable.py
|
|
7
7
|
serializable/helpers.py
|
|
8
8
|
serializable/primitive_types.py
|
|
9
|
+
serializable/py.typed
|
|
9
10
|
serializable/serializable.py
|
|
10
11
|
serializable/version.py
|
|
11
12
|
serializable.egg-info/PKG-INFO
|
|
@@ -21,6 +22,8 @@ tests/test_keyword_aliases.py
|
|
|
21
22
|
tests/test_namedtuple.py
|
|
22
23
|
tests/test_nesting.py
|
|
23
24
|
tests/test_object.py
|
|
25
|
+
tests/test_pickle.py
|
|
24
26
|
tests/test_primitive_values.py
|
|
25
27
|
tests/test_set.py
|
|
26
|
-
tests/test_tuple.py
|
|
28
|
+
tests/test_tuple.py
|
|
29
|
+
tests/test_typing.py
|
|
@@ -25,3 +25,17 @@ class A(Serializable):
|
|
|
25
25
|
def test_serializable_default_to_dict():
|
|
26
26
|
a = A(10, 1)
|
|
27
27
|
eq_(a, A.from_dict(a.to_dict()))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WithKeywordOnlyOption(Serializable):
|
|
31
|
+
def __init__(self, x, *, scale=1):
|
|
32
|
+
# the keyword-only option isn't stored, so it can't be serialized
|
|
33
|
+
self.x = x * scale
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_keyword_only_args_excluded_from_default_to_dict():
|
|
37
|
+
# keyword-only args are constructor options rather than serialized state
|
|
38
|
+
# (varcode's SpliceOutcomeSet relies on this)
|
|
39
|
+
obj = WithKeywordOnlyOption(10, scale=2)
|
|
40
|
+
eq_(obj.to_dict(), {"x": 20})
|
|
41
|
+
eq_(obj, WithKeywordOnlyOption.from_json(obj.to_json()))
|
|
@@ -96,3 +96,10 @@ def test_renamed_keyword_inheritance():
|
|
|
96
96
|
eq_(obj.y, 2)
|
|
97
97
|
assert not hasattr(obj, "old_x")
|
|
98
98
|
assert not hasattr(obj, "old_y")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_from_dict_does_not_modify_input():
|
|
102
|
+
d = {"old_x": 1, "old_gone": 2}
|
|
103
|
+
obj = TestClassWithKeywordAliases.from_dict(d)
|
|
104
|
+
eq_(obj.x, 1)
|
|
105
|
+
eq_(d, {"old_x": 1, "old_gone": 2})
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
import copy
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from serializable import (
|
|
5
|
+
Serializable,
|
|
6
|
+
from_json,
|
|
7
|
+
from_serializable_repr,
|
|
8
|
+
to_json,
|
|
9
|
+
to_serializable_repr,
|
|
10
|
+
)
|
|
2
11
|
|
|
3
12
|
from .common import eq_
|
|
4
13
|
|
|
@@ -42,3 +51,27 @@ def test_object_with_dict_values():
|
|
|
42
51
|
def test_object_with_set_values():
|
|
43
52
|
x = B()
|
|
44
53
|
eq_(x, from_serializable_repr(to_serializable_repr(x)))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
nested = {
|
|
57
|
+
(1, 2): [A(1), B()],
|
|
58
|
+
"class": A,
|
|
59
|
+
"inner": {"t": (A({(3,): {4}}),), 5: "int key"},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_from_serializable_repr_does_not_modify_input():
|
|
64
|
+
serialized = to_serializable_repr(nested)
|
|
65
|
+
snapshot = copy.deepcopy(serialized)
|
|
66
|
+
eq_(nested, from_serializable_repr(serialized))
|
|
67
|
+
eq_(serialized, snapshot)
|
|
68
|
+
# decoding the same representation twice used to fail
|
|
69
|
+
eq_(nested, from_serializable_repr(serialized))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_from_json_matches_from_serializable_repr():
|
|
73
|
+
# from_json reconstructs objects while parsing; it must agree with
|
|
74
|
+
# decoding the parsed JSON in a separate pass
|
|
75
|
+
json_string = to_json(nested)
|
|
76
|
+
eq_(nested, from_json(json_string))
|
|
77
|
+
eq_(from_json(json_string), from_serializable_repr(json.loads(json_string)))
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import datetime
|
|
15
|
+
import io
|
|
16
|
+
import pickle
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from serializable import (
|
|
22
|
+
DataclassSerializable,
|
|
23
|
+
Serializable,
|
|
24
|
+
from_serializable_repr,
|
|
25
|
+
to_serializable_repr,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .common import eq_
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Point(Serializable):
|
|
32
|
+
def __init__(self, x, y):
|
|
33
|
+
self.x = x
|
|
34
|
+
self.y = y
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Pair(DataclassSerializable):
|
|
39
|
+
left: object
|
|
40
|
+
right: object
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Interned(Serializable):
|
|
44
|
+
"""from_dict returns a cached instance, like pyensembl's EnsemblRelease"""
|
|
45
|
+
|
|
46
|
+
_instances = {}
|
|
47
|
+
|
|
48
|
+
def __init__(self, name):
|
|
49
|
+
self.name = name
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, state_dict):
|
|
53
|
+
name = state_dict["name"]
|
|
54
|
+
if name not in cls._instances:
|
|
55
|
+
cls._instances[name] = cls(name)
|
|
56
|
+
return cls._instances[name]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
nested = Point([Point(1, 2), (3, 4)], {(5, 6): {7, 8}, "pair": Pair("a", Point(9, 10))})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Pre12Pickler(pickle.Pickler):
|
|
63
|
+
"""Pickles objects the way serializable < 1.2.0 did."""
|
|
64
|
+
|
|
65
|
+
def reducer_override(self, obj):
|
|
66
|
+
if isinstance(obj, (Serializable, DataclassSerializable)):
|
|
67
|
+
return (from_serializable_repr, (to_serializable_repr(obj),))
|
|
68
|
+
return NotImplemented
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1))
|
|
72
|
+
def test_pickle_roundtrip_all_protocols(protocol):
|
|
73
|
+
eq_(nested, pickle.loads(pickle.dumps(nested, protocol=protocol)))
|
|
74
|
+
pair = Pair(nested, "b")
|
|
75
|
+
eq_(pair, pickle.loads(pickle.dumps(pair, protocol=protocol)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_load_pickle_written_before_1_2():
|
|
79
|
+
objects = [nested, Pair(1, nested)]
|
|
80
|
+
buffer = io.BytesIO()
|
|
81
|
+
Pre12Pickler(buffer).dump(objects)
|
|
82
|
+
eq_(objects, pickle.loads(buffer.getvalue()))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_pickle_reconstructs_with_from_dict():
|
|
86
|
+
obj = Interned.from_dict({"name": "a"})
|
|
87
|
+
assert pickle.loads(pickle.dumps(obj)) is obj
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_pickle_value_without_serializable_representation():
|
|
91
|
+
# values in to_dict() are pickled natively, so they don't need a
|
|
92
|
+
# JSON-compatible representation
|
|
93
|
+
obj = Point(datetime.date(2026, 10, 6), None)
|
|
94
|
+
with pytest.raises(ValueError):
|
|
95
|
+
obj.to_json()
|
|
96
|
+
eq_(obj, pickle.loads(pickle.dumps(obj)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_deepcopy():
|
|
100
|
+
copied = copy.deepcopy(nested)
|
|
101
|
+
eq_(nested, copied)
|
|
102
|
+
assert copied.x is not nested.x
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
|
|
13
|
+
import inspect
|
|
14
|
+
import typing
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from serializable import DataclassSerializable, Serializable, helpers
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Point(Serializable):
|
|
23
|
+
def __init__(self, x):
|
|
24
|
+
self.x = x
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DataclassPoint(DataclassSerializable):
|
|
29
|
+
x: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def annotated_objects():
|
|
33
|
+
yield Point
|
|
34
|
+
yield DataclassPoint
|
|
35
|
+
for cls in (Serializable, DataclassSerializable):
|
|
36
|
+
for _, method in inspect.getmembers(cls, callable):
|
|
37
|
+
if (getattr(method, "__module__", None) or "").startswith("serializable"):
|
|
38
|
+
yield method
|
|
39
|
+
for _, function in inspect.getmembers(helpers, inspect.isfunction):
|
|
40
|
+
if function.__module__ == helpers.__name__:
|
|
41
|
+
yield function
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("obj", list(annotated_objects()), ids=lambda obj: obj.__qualname__)
|
|
45
|
+
def test_annotations_evaluate_at_runtime(obj):
|
|
46
|
+
# annotations must stay evaluable on the oldest supported Python (no
|
|
47
|
+
# `X | Y` unions before 3.10) for tools which call get_type_hints
|
|
48
|
+
typing.get_type_hints(obj)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|