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.
Files changed (31) hide show
  1. {serializable-1.1.2 → serializable-1.2.1}/PKG-INFO +10 -2
  2. {serializable-1.1.2 → serializable-1.2.1}/README.md +5 -1
  3. {serializable-1.1.2 → serializable-1.2.1}/pyproject.toml +13 -0
  4. {serializable-1.1.2 → serializable-1.2.1}/serializable/dataclass_serializable.py +15 -9
  5. {serializable-1.1.2 → serializable-1.2.1}/serializable/helpers.py +81 -48
  6. serializable-1.2.1/serializable/py.typed +0 -0
  7. {serializable-1.1.2 → serializable-1.2.1}/serializable/serializable.py +35 -32
  8. {serializable-1.1.2 → serializable-1.2.1}/serializable/version.py +1 -1
  9. {serializable-1.1.2 → serializable-1.2.1}/serializable.egg-info/PKG-INFO +10 -2
  10. {serializable-1.1.2 → serializable-1.2.1}/serializable.egg-info/SOURCES.txt +4 -1
  11. {serializable-1.1.2 → serializable-1.2.1}/serializable.egg-info/requires.txt +1 -0
  12. {serializable-1.1.2 → serializable-1.2.1}/tests/test_default_to_dict_implementation.py +14 -0
  13. {serializable-1.1.2 → serializable-1.2.1}/tests/test_keyword_aliases.py +7 -0
  14. {serializable-1.1.2 → serializable-1.2.1}/tests/test_nesting.py +34 -1
  15. serializable-1.2.1/tests/test_pickle.py +102 -0
  16. serializable-1.2.1/tests/test_typing.py +48 -0
  17. {serializable-1.1.2 → serializable-1.2.1}/LICENSE +0 -0
  18. {serializable-1.1.2 → serializable-1.2.1}/serializable/__init__.py +0 -0
  19. {serializable-1.1.2 → serializable-1.2.1}/serializable/primitive_types.py +0 -0
  20. {serializable-1.1.2 → serializable-1.2.1}/serializable.egg-info/dependency_links.txt +0 -0
  21. {serializable-1.1.2 → serializable-1.2.1}/serializable.egg-info/top_level.txt +0 -0
  22. {serializable-1.1.2 → serializable-1.2.1}/setup.cfg +0 -0
  23. {serializable-1.1.2 → serializable-1.2.1}/setup.py +0 -0
  24. {serializable-1.1.2 → serializable-1.2.1}/tests/test_class.py +0 -0
  25. {serializable-1.1.2 → serializable-1.2.1}/tests/test_dataclass_serializable.py +0 -0
  26. {serializable-1.1.2 → serializable-1.2.1}/tests/test_function.py +0 -0
  27. {serializable-1.1.2 → serializable-1.2.1}/tests/test_namedtuple.py +0 -0
  28. {serializable-1.1.2 → serializable-1.2.1}/tests/test_object.py +0 -0
  29. {serializable-1.1.2 → serializable-1.2.1}/tests/test_primitive_values.py +0 -0
  30. {serializable-1.1.2 → serializable-1.2.1}/tests/test_set.py +0 -0
  31. {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.2
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, from_serializable_repr, to_json, to_serializable_repr
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 | None]] = {}
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
- return {f.name: getattr(self, f.name) for f in fields(self)}
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: dict[str, Any]):
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
- return (from_serializable_repr, (to_serializable_repr(self),))
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 return_primitive
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 object's class.
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__ or __new__ and hoping that they match fields defined on the
31
- # object
47
+ # __init__ and hoping that they match fields defined on the object
32
48
  try:
33
- init_code = obj.__init__.__func__.__code__
34
- except AttributeError:
35
- try:
36
- init_code = obj.__new__.__func__.__code__
37
- except AttributeError as exc:
38
- # if object is a namedtuple then we can return its fields
39
- # as the required initial args
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
- nonself_arg_names = arg_names[1:]
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 from_serializable_dict(x):
198
+ def _from_reconstructed_dict(x):
189
199
  """
190
- Reconstruct a dictionary by recursively reconstructing all its keys and
191
- values.
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.pop("__module__"), x.pop("__name__"))
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
- non_string_key_objects = [
206
- from_json(serialized_key) for serialized_key in x.pop(SERIALIZED_DICTIONARY_KEYS_FIELD, [])
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
- if "__class__" in converted_dict:
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
- @return_primitive
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
- @return_primitive
271
- def from_serializable_repr(x):
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
- return from_serializable_repr(json.loads(json_string))
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 typing import ClassVar
14
+ from __future__ import annotations
15
15
 
16
- from .helpers import (
17
- from_json,
18
- from_serializable_repr,
19
- simple_object_to_dict,
20
- to_json,
21
- to_serializable_repr,
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 to __init__
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
- # I wish I could just return (self.from_dict, (self.to_dict(),) but
134
- # Python 2 won't pickle the class method from_dict so instead have to
135
- # use globally defined functions.
136
- return (from_serializable_repr, (to_serializable_repr(self),))
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,4 +1,4 @@
1
- __version__ = "1.1.2"
1
+ __version__ = "1.2.1"
2
2
 
3
3
 
4
4
  def print_version():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: serializable
3
- Version: 1.1.2
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
@@ -5,6 +5,7 @@ simplejson>=3.0.0
5
5
  pytest
6
6
  pytest-cov
7
7
  ruff
8
+ mypy
8
9
  coveralls
9
10
  build
10
11
  twine
@@ -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
- from serializable import Serializable, from_serializable_repr, to_serializable_repr
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