serializable 1.1.2__tar.gz → 1.2.0__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 (29) hide show
  1. {serializable-1.1.2 → serializable-1.2.0}/PKG-INFO +6 -2
  2. {serializable-1.1.2 → serializable-1.2.0}/README.md +5 -1
  3. {serializable-1.1.2 → serializable-1.2.0}/serializable/dataclass_serializable.py +5 -3
  4. {serializable-1.1.2 → serializable-1.2.0}/serializable/helpers.py +69 -40
  5. {serializable-1.1.2 → serializable-1.2.0}/serializable/serializable.py +14 -17
  6. {serializable-1.1.2 → serializable-1.2.0}/serializable/version.py +1 -1
  7. {serializable-1.1.2 → serializable-1.2.0}/serializable.egg-info/PKG-INFO +6 -2
  8. {serializable-1.1.2 → serializable-1.2.0}/serializable.egg-info/SOURCES.txt +1 -0
  9. {serializable-1.1.2 → serializable-1.2.0}/tests/test_default_to_dict_implementation.py +14 -0
  10. {serializable-1.1.2 → serializable-1.2.0}/tests/test_keyword_aliases.py +7 -0
  11. {serializable-1.1.2 → serializable-1.2.0}/tests/test_nesting.py +34 -1
  12. serializable-1.2.0/tests/test_pickle.py +102 -0
  13. {serializable-1.1.2 → serializable-1.2.0}/LICENSE +0 -0
  14. {serializable-1.1.2 → serializable-1.2.0}/pyproject.toml +0 -0
  15. {serializable-1.1.2 → serializable-1.2.0}/serializable/__init__.py +0 -0
  16. {serializable-1.1.2 → serializable-1.2.0}/serializable/primitive_types.py +0 -0
  17. {serializable-1.1.2 → serializable-1.2.0}/serializable.egg-info/dependency_links.txt +0 -0
  18. {serializable-1.1.2 → serializable-1.2.0}/serializable.egg-info/requires.txt +0 -0
  19. {serializable-1.1.2 → serializable-1.2.0}/serializable.egg-info/top_level.txt +0 -0
  20. {serializable-1.1.2 → serializable-1.2.0}/setup.cfg +0 -0
  21. {serializable-1.1.2 → serializable-1.2.0}/setup.py +0 -0
  22. {serializable-1.1.2 → serializable-1.2.0}/tests/test_class.py +0 -0
  23. {serializable-1.1.2 → serializable-1.2.0}/tests/test_dataclass_serializable.py +0 -0
  24. {serializable-1.1.2 → serializable-1.2.0}/tests/test_function.py +0 -0
  25. {serializable-1.1.2 → serializable-1.2.0}/tests/test_namedtuple.py +0 -0
  26. {serializable-1.1.2 → serializable-1.2.0}/tests/test_object.py +0 -0
  27. {serializable-1.1.2 → serializable-1.2.0}/tests/test_primitive_values.py +0 -0
  28. {serializable-1.1.2 → serializable-1.2.0}/tests/test_set.py +0 -0
  29. {serializable-1.1.2 → serializable-1.2.0}/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.0
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
@@ -274,9 +274,13 @@ helpers.
274
274
 
275
275
  A derived class must either:
276
276
 
277
- - have a member data matching the name of each argument to `__init__`
277
+ - have a member data matching the name of each positional argument to `__init__`
278
278
  - provide a user-defined `to_dict()` method which returns a dictionary whose keys match the arguments to `__init__`
279
279
 
280
+ 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.
281
+
282
+ 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.
283
+
280
284
  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
285
 
282
286
  ## 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
@@ -43,7 +43,7 @@ from __future__ import annotations
43
43
  from dataclasses import fields
44
44
  from typing import Any, ClassVar
45
45
 
46
- from .helpers import from_json, from_serializable_repr, to_json, to_serializable_repr
46
+ from .helpers import from_json, to_json
47
47
 
48
48
 
49
49
  class DataclassSerializable:
@@ -89,5 +89,7 @@ class DataclassSerializable:
89
89
  def __reduce__(self):
90
90
  """Pickle via the same to_dict / from_dict path used for JSON so
91
91
  pickled objects round-trip even when field order or internal
92
- representation changes between releases."""
93
- return (from_serializable_repr, (to_serializable_repr(self),))
92
+ representation changes between releases. Pickles written before
93
+ serializable 1.2.0 call from_serializable_repr instead, which remains
94
+ supported."""
95
+ return (type(self).from_dict, (self.to_dict(),))
@@ -15,36 +15,42 @@ Helper functions for deconstructing classes, functions, and user-defined
15
15
  objects into serializable types.
16
16
  """
17
17
 
18
+ from functools import lru_cache
18
19
  from types import BuiltinFunctionType, FunctionType
19
20
 
20
21
  import simplejson as json
21
22
 
22
- from .primitive_types import return_primitive
23
+ from .primitive_types import PRIMITIVE_TYPES
23
24
 
24
25
 
25
26
  def init_arg_names(obj):
26
27
  """
27
- Names of arguments to __init__ method of this object's class.
28
+ Names of the positional arguments to the __init__ method of this
29
+ object's class (excluding self).
30
+
31
+ Keyword-only arguments are deliberately excluded, so a class can accept
32
+ constructor options which aren't part of its serialized state.
28
33
  """
34
+ return _class_init_arg_names(type(obj))
35
+
36
+
37
+ # Cached per class since this is called on every to_dict/__eq__/__hash__ of
38
+ # a Serializable which doesn't override to_dict. Assumes a class's __init__
39
+ # isn't replaced after its first instance has been serialized.
40
+ @lru_cache(maxsize=1024)
41
+ def _class_init_arg_names(cls):
29
42
  # 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
43
+ # __init__ and hoping that they match fields defined on the object
32
44
  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]
45
+ init_code = cls.__init__.__code__
46
+ except AttributeError as exc:
47
+ # if object is a namedtuple then we can return its fields
48
+ # as the required initial args
49
+ if hasattr(cls, "_fields"):
50
+ return cls._fields
51
+ raise ValueError(f"Cannot determine args to {cls.__qualname__}.__init__") from exc
45
52
  # drop self argument
46
- nonself_arg_names = arg_names[1:]
47
- return nonself_arg_names
53
+ return init_code.co_varnames[1 : init_code.co_argcount]
48
54
 
49
55
 
50
56
  def simple_object_to_dict(self):
@@ -185,10 +191,12 @@ def dict_to_serializable_repr(x):
185
191
  return result
186
192
 
187
193
 
188
- def from_serializable_dict(x):
194
+ def _from_reconstructed_dict(x):
189
195
  """
190
- Reconstruct a dictionary by recursively reconstructing all its keys and
191
- values.
196
+ Given a dictionary from a serializable representation whose values have
197
+ already been reconstructed, return the object it represents: a class or
198
+ function, an instance of a user-defined class, or a dictionary (restoring
199
+ any non-string keys). May modify `x`.
192
200
 
193
201
  This is the most hackish part since we rely on key names such as
194
202
  __name__, __class__, __module__ as metadata about how to reconstruct
@@ -200,26 +208,38 @@ def from_serializable_dict(x):
200
208
  flattened result of to_dict() for user-defined objects.
201
209
  """
202
210
  if "__name__" in x:
203
- return _lookup_value(x.pop("__module__"), x.pop("__name__"))
211
+ return _lookup_value(x["__module__"], x["__name__"])
212
+
213
+ serialized_keys = x.pop(SERIALIZED_DICTIONARY_KEYS_FIELD, None)
214
+ if serialized_keys:
215
+ non_string_key_objects = [from_json(serialized_key) for serialized_key in serialized_keys]
216
+ converted_dict = type(x)()
217
+ for k, v in x.items():
218
+ serialized_key_index = parse_serialized_keys_index(k)
219
+ if serialized_key_index is not None:
220
+ k = non_string_key_objects[serialized_key_index]
221
+ converted_dict[k] = v
222
+ x = converted_dict
223
+
224
+ if "__class__" not in x:
225
+ return x
226
+ class_object = x.pop("__class__")
227
+ if "__value__" in x:
228
+ return class_object(x["__value__"])
229
+ if hasattr(class_object, "from_dict"):
230
+ return class_object.from_dict(x)
231
+ return class_object(**x)
232
+
204
233
 
205
- non_string_key_objects = [
206
- from_json(serialized_key) for serialized_key in x.pop(SERIALIZED_DICTIONARY_KEYS_FIELD, [])
207
- ]
234
+ def from_serializable_dict(x):
235
+ """
236
+ Reconstruct a dictionary, or the object it represents, by recursively
237
+ reconstructing all its keys and values. Does not modify `x`.
238
+ """
208
239
  converted_dict = type(x)()
209
240
  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
241
  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
242
+ return _from_reconstructed_dict(converted_dict)
223
243
 
224
244
 
225
245
  def list_to_serializable_repr(x):
@@ -242,12 +262,13 @@ def to_dict(obj):
242
262
  raise ValueError(f"Cannot convert {obj} : {type(obj)} to dictionary") from exc
243
263
 
244
264
 
245
- @return_primitive
246
265
  def to_serializable_repr(x):
247
266
  """
248
267
  Convert an instance of Serializable or a primitive collection containing
249
268
  such instances into serializable types.
250
269
  """
270
+ if isinstance(x, PRIMITIVE_TYPES):
271
+ return x
251
272
  t = type(x)
252
273
  if isinstance(x, list):
253
274
  return list_to_serializable_repr(x)
@@ -267,8 +288,12 @@ def to_serializable_repr(x):
267
288
  return state_dictionary
268
289
 
269
290
 
270
- @return_primitive
271
291
  def from_serializable_repr(x):
292
+ """
293
+ Inverse of to_serializable_repr. Does not modify `x`.
294
+ """
295
+ if isinstance(x, PRIMITIVE_TYPES):
296
+ return x
272
297
  t = type(x)
273
298
  if isinstance(x, list):
274
299
  return t([from_serializable_repr(element) for element in x])
@@ -286,4 +311,8 @@ def to_json(x):
286
311
 
287
312
 
288
313
  def from_json(json_string):
289
- return from_serializable_repr(json.loads(json_string))
314
+ """
315
+ Inverse of to_json. Objects are reconstructed bottom-up while parsing,
316
+ equivalent to (but faster than) from_serializable_repr(json.loads(...)).
317
+ """
318
+ return json.loads(json_string, object_hook=_from_reconstructed_dict)
@@ -13,13 +13,7 @@
13
13
 
14
14
  from typing import ClassVar
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
+ from .helpers import from_json, simple_object_to_dict, to_json
23
17
 
24
18
 
25
19
  class Serializable:
@@ -48,8 +42,10 @@ class Serializable:
48
42
  (bool, string, int, float), primitive collections
49
43
  (int, list, tuple, set) or instances of Serializable.
50
44
 
51
- The default implementation is to assume all the arguments to __init__
52
- have fields of the same name on a serializable object.
45
+ The default implementation is to assume all the positional arguments
46
+ to __init__ have fields of the same name on a serializable object.
47
+ Keyword-only arguments are excluded, so they can be used for
48
+ constructor options which aren't part of the serialized state.
53
49
  """
54
50
  return simple_object_to_dict(self)
55
51
 
@@ -90,9 +86,9 @@ class Serializable:
90
86
  def from_dict(cls, state_dict):
91
87
  """
92
88
  Given a dictionary of flattened fields (result of calling to_dict()),
93
- returns an instance.
89
+ returns an instance. Does not modify `state_dict`.
94
90
  """
95
- state_dict = cls._reconstruct_nested_objects(state_dict)
91
+ state_dict = cls._reconstruct_nested_objects(dict(state_dict))
96
92
  return cls(**cls._update_kwargs(state_dict))
97
93
 
98
94
  def to_json(self):
@@ -127,10 +123,11 @@ class Serializable:
127
123
  def __reduce__(self):
128
124
  """
129
125
  Overriding this method directs the default pickler to reconstruct
130
- this object using our from_dict method.
131
- """
126
+ this object using our from_dict method, so pickles survive the same
127
+ changes to a class (e.g. renamed keywords) as JSON does. Values in
128
+ to_dict() are pickled natively.
132
129
 
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),))
130
+ Pickles written before serializable 1.2.0 call from_serializable_repr
131
+ instead, which remains supported.
132
+ """
133
+ return (type(self).from_dict, (self.to_dict(),))
@@ -1,4 +1,4 @@
1
- __version__ = "1.1.2"
1
+ __version__ = "1.2.0"
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.0
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
@@ -274,9 +274,13 @@ helpers.
274
274
 
275
275
  A derived class must either:
276
276
 
277
- - have a member data matching the name of each argument to `__init__`
277
+ - have a member data matching the name of each positional argument to `__init__`
278
278
  - provide a user-defined `to_dict()` method which returns a dictionary whose keys match the arguments to `__init__`
279
279
 
280
+ 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.
281
+
282
+ 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.
283
+
280
284
  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
285
 
282
286
  ## Limitations
@@ -21,6 +21,7 @@ tests/test_keyword_aliases.py
21
21
  tests/test_namedtuple.py
22
22
  tests/test_nesting.py
23
23
  tests/test_object.py
24
+ tests/test_pickle.py
24
25
  tests/test_primitive_values.py
25
26
  tests/test_set.py
26
27
  tests/test_tuple.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
- 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
File without changes
File without changes
File without changes