Skip to content

Commit d35e047

Browse files
committed
serialize and deserilize mro
1 parent d773b3e commit d35e047

File tree

4 files changed

+184
-5
lines changed

4 files changed

+184
-5
lines changed

src/compas/data/data.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ def __init__(self, name=None):
7575
def __dtype__(self):
7676
return "{}/{}".format(".".join(self.__class__.__module__.split(".")[:2]), self.__class__.__name__)
7777

78+
@classmethod
79+
def __cls_dtype__(cls):
80+
return "{}/{}".format(".".join(cls.__module__.split(".")[:2]), cls.__name__)
81+
7882
@property
7983
def __data__(self):
8084
raise NotImplementedError

src/compas/data/encoders.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,16 @@
4040
numpy_support = False
4141

4242

43-
def cls_from_dtype(dtype): # type: (...) -> Type[Data]
43+
def cls_from_dtype(dtype, mro=None): # type: (...) -> Type[Data]
4444
"""Get the class object corresponding to a COMPAS data type specification.
4545
4646
Parameters
4747
----------
4848
dtype : str
4949
The data type of the COMPAS object in the following format:
5050
'{}/{}'.format(o.__class__.__module__, o.__class__.__name__).
51+
mro : list[str], optional
52+
The MRO of the class, all superclasses of the class that can be used if given dtype is not found.
5153
5254
Returns
5355
-------
@@ -63,9 +65,23 @@ def cls_from_dtype(dtype): # type: (...) -> Type[Data]
6365
If the module doesn't contain the specified data type.
6466
6567
"""
66-
mod_name, attr_name = dtype.split("/")
67-
module = __import__(mod_name, fromlist=[attr_name])
68-
return getattr(module, attr_name)
68+
69+
if mro is None:
70+
full_mro = [dtype]
71+
else:
72+
full_mro = [dtype] + mro
73+
74+
for dtype in full_mro:
75+
mod_name, attr_name = dtype.split("/")
76+
try:
77+
module = __import__(mod_name, fromlist=[attr_name])
78+
return getattr(module, attr_name)
79+
except ImportError:
80+
continue
81+
except AttributeError:
82+
continue
83+
84+
raise ValueError("No class found in MRO: {}".format(mro))
6985

7086

7187
class DataEncoder(json.JSONEncoder):
@@ -220,7 +236,7 @@ def object_hook(self, o):
220236
return o
221237

222238
try:
223-
cls = cls_from_dtype(o["dtype"])
239+
cls = cls_from_dtype(o["dtype"], o.get("mro", None))
224240

225241
except ValueError:
226242
raise DecoderError(

src/compas/datastructures/datastructure.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,41 @@ def __init__(self, attributes=None, name=None):
2121
self._aabb = None
2222
self._obb = None
2323

24+
def __get_mro__(self):
25+
mro = []
26+
for cls in self.__class__.__mro__:
27+
if cls == self.__class__:
28+
continue
29+
if cls == Datastructure:
30+
break
31+
mro.append(cls.__cls_dtype__())
32+
return mro
33+
34+
def __jsondump__(self, minimal=False):
35+
"""Return the required information for serialization with the COMPAS JSON serializer.
36+
37+
Parameters
38+
----------
39+
minimal : bool, optional
40+
If True, exclude the GUID from the dump dict.
41+
42+
Returns
43+
-------
44+
dict
45+
46+
"""
47+
state = {
48+
"dtype": self.__dtype__,
49+
"data": self.__data__,
50+
"mro": self.__get_mro__(),
51+
}
52+
if minimal:
53+
return state
54+
if self._name is not None:
55+
state["name"] = self._name
56+
state["guid"] = str(self.guid)
57+
return state
58+
2459
@property
2560
def aabb(self):
2661
if self._aabb is None:
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import pytest
2+
from compas.datastructures import Datastructure
3+
from compas.data import json_dumps, json_loads
4+
5+
6+
class Level1(Datastructure):
7+
def __init__(self, attributes=None, name=None):
8+
super(Level1, self).__init__(attributes=attributes, name=name)
9+
self.level1_attr = "level1"
10+
11+
@property
12+
def __data__(self):
13+
return {"attributes": self.attributes, "level1_attr": self.level1_attr}
14+
15+
@classmethod
16+
def __from_data__(cls, data):
17+
obj = cls(attributes=data.get("attributes"))
18+
obj.level1_attr = data.get("level1_attr", "")
19+
return obj
20+
21+
22+
@pytest.fixture
23+
def level2():
24+
# Level2 is a custom class that is not available outside of this local scope
25+
class Level2(Level1):
26+
def __init__(self, attributes=None, name=None):
27+
super(Level2, self).__init__(attributes=attributes, name=name)
28+
self.level2_attr = "level2"
29+
30+
@property
31+
def __data__(self):
32+
data = super(Level2, self).__data__
33+
data["level2_attr"] = self.level2_attr
34+
return data
35+
36+
@classmethod
37+
def __from_data__(cls, data):
38+
obj = super(Level2, cls).__from_data__(data)
39+
obj.level2_attr = data.get("level2_attr", "")
40+
return obj
41+
42+
# return an instance of Level2
43+
return Level2(name="test")
44+
45+
46+
@pytest.fixture
47+
def level3():
48+
# Level2 and Level3 are custom classes that are not available outside of this local scope
49+
class Level2(Level1):
50+
def __init__(self, attributes=None, name=None):
51+
super(Level2, self).__init__(attributes=attributes, name=name)
52+
self.level2_attr = "level2"
53+
54+
@property
55+
def __data__(self):
56+
data = super(Level2, self).__data__
57+
data["level2_attr"] = self.level2_attr
58+
return data
59+
60+
@classmethod
61+
def __from_data__(cls, data):
62+
obj = super(Level2, cls).__from_data__(data)
63+
obj.level2_attr = data.get("level2_attr", "")
64+
return obj
65+
66+
class Level3(Level2):
67+
def __init__(self, attributes=None, name=None):
68+
super(Level3, self).__init__(attributes=attributes, name=name)
69+
self.level3_attr = "level3"
70+
71+
@property
72+
def __data__(self):
73+
data = super(Level3, self).__data__
74+
data["level3_attr"] = self.level3_attr
75+
return data
76+
77+
@classmethod
78+
def __from_data__(cls, data):
79+
obj = super(Level3, cls).__from_data__(data)
80+
obj.level3_attr = data.get("level3_attr", "")
81+
return obj
82+
83+
# return an instance of Level3
84+
return Level3(name="test")
85+
86+
87+
def test_mro_fallback(level2):
88+
assert level2.__jsondump__()["dtype"] == "test_datastructure/Level2"
89+
# Level2 should serialize Level1 into the mro
90+
assert level2.__jsondump__()["mro"] == ["test_datastructure/Level1"]
91+
92+
dumped = json_dumps(level2)
93+
loaded = json_loads(dumped)
94+
95+
# The loaded object should be deserialized as the closes available class: Level1
96+
assert loaded.__class__ == Level1
97+
assert loaded.__jsondump__()["dtype"] == "test_datastructure/Level1"
98+
assert loaded.__jsondump__()["mro"] == []
99+
100+
# level1 attributes should still be available
101+
assert loaded.level1_attr == "level1"
102+
# Meanwhile, level2 attributes will be discarded
103+
assert not hasattr(loaded, "level2_attr")
104+
105+
106+
def test_mro_fallback_multi_level(level3):
107+
assert level3.__jsondump__()["dtype"] == "test_datastructure/Level3"
108+
# Level3 should serialize Level2 and Level1 into the mro
109+
assert level3.__jsondump__()["mro"] == ["test_datastructure/Level2", "test_datastructure/Level1"]
110+
111+
dumped = json_dumps(level3)
112+
loaded = json_loads(dumped)
113+
114+
# The loaded object should be deserialized as the closes available class: Level1
115+
assert loaded.__class__ == Level1
116+
assert loaded.__jsondump__()["dtype"] == "test_datastructure/Level1"
117+
assert loaded.__jsondump__()["mro"] == []
118+
119+
# level1 attributes should still be available
120+
assert loaded.level1_attr == "level1"
121+
122+
# level2 and 3 attributes will be discarded
123+
assert not hasattr(loaded, "level2_attr")
124+
assert not hasattr(loaded, "level3_attr")

0 commit comments

Comments
 (0)