Skip to content

Commit c1ac434

Browse files
authored
Merge pull request #1460 from compas-dev/mro
serialize and deserilize mro
2 parents d773b3e + ff7344e commit c1ac434

File tree

6 files changed

+289
-5
lines changed

6 files changed

+289
-5
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
* Added `inheritance` field to `__jsondump__` of `compas.datastructures.Datastructure` to allow for deserialization to closest available superclass of custom datastructures.
13+
1214
### Changed
1315

1416
### Removed

docs/userguide/advanced.serialisation.rst

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,50 @@ which will help with IntelliSense and code completion.
149149
boxes: List[Box] = session['boxes']
150150
151151
152+
Inheritance
153+
===========
154+
155+
When working with custom classes that inherit from COMPAS datastructures, COMPAS will encode the inheritance chain in the serialized data. This allows the object to be reconstructed using the closest available superclass if the custom class is not available in the environment where the data is loaded.
156+
For example, a user can create a custom mesh class and serialize it to JSON:
157+
158+
.. code-block:: python
159+
160+
from compas.datastructures import Mesh
161+
from compas import json_dump
162+
163+
class CustomMesh(Mesh):
164+
def __init__(self, *args, **kwargs):
165+
super(CustomMesh, self).__init__(*args, **kwargs)
166+
self.custom_mesh_attr = "custom_mesh"
167+
168+
@property
169+
def __data__(self):
170+
data = super(CustomMesh, self).__data__
171+
data["custom_mesh_attr"] = self.custom_mesh_attr
172+
return data
173+
174+
@classmethod
175+
def __from_data__(cls, data):
176+
obj = super(CustomMesh, cls).__from_data__(data)
177+
obj.custom_mesh_attr = data.get("custom_mesh_attr", "")
178+
return obj
179+
180+
# Create and serialize a custom mesh
181+
custom_mesh = CustomMesh(name="test")
182+
json_dump(custom_mesh, 'custom_mesh.json')
183+
184+
If another user loads "custom_mesh.json" in an environment where the CustomMesh class is not available, COMPAS will reconstruct the object as an instance of its closest available superclass, which in this case is the regular Mesh class:
185+
186+
.. code-block:: python
187+
188+
from compas.datastructures import Mesh
189+
from compas import json_load
190+
191+
mesh = json_load('custom_mesh.json')
192+
assert isinstance(mesh, Mesh) # This will be True
193+
194+
195+
152196
Validation
153197
==========
154198

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 __clstype__(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, inheritance=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+
inheritance : list[str], optional
52+
The inheritance chain of this class, a list of superclasses 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 inheritance is None:
70+
full_inheritance = [dtype]
71+
else:
72+
full_inheritance = [dtype] + inheritance
73+
74+
for dtype in full_inheritance:
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 inheritance chain: {}".format(full_inheritance))
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("inheritance", None))
224240

225241
except ValueError:
226242
raise DecoderError(

src/compas/datastructures/datastructure.py

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

24+
@property
25+
def __inheritance__(self):
26+
"""Get the inheritance chain of the datastructure.
27+
Until one level above the Datastructure class (eg. Mesh, Graph, ...).
28+
29+
Returns
30+
-------
31+
list[str]
32+
The inheritance chain of the datastructure.
33+
34+
"""
35+
inheritance = []
36+
for cls in self.__class__.__mro__:
37+
if cls == self.__class__:
38+
continue
39+
if cls == Datastructure:
40+
break
41+
inheritance.append(cls.__clstype__())
42+
return inheritance
43+
44+
def __jsondump__(self, minimal=False):
45+
"""Return the required information for serialization with the COMPAS JSON serializer.
46+
47+
Parameters
48+
----------
49+
minimal : bool, optional
50+
If True, exclude the GUID from the dump dict.
51+
52+
Returns
53+
-------
54+
dict
55+
56+
"""
57+
state = {
58+
"dtype": self.__dtype__,
59+
"data": self.__data__,
60+
"inheritance": self.__inheritance__,
61+
}
62+
if minimal:
63+
return state
64+
if self._name is not None:
65+
state["name"] = self._name
66+
state["guid"] = str(self.guid)
67+
return state
68+
2469
@property
2570
def aabb(self):
2671
if self._aabb is None:
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import pytest
2+
import compas
3+
from compas.datastructures import Datastructure
4+
from compas.datastructures import Mesh
5+
from compas.data import json_dumps, json_loads
6+
7+
8+
class Level1(Datastructure):
9+
def __init__(self, attributes=None, name=None):
10+
super(Level1, self).__init__(attributes=attributes, name=name)
11+
self.level1_attr = "level1"
12+
13+
@property
14+
def __data__(self):
15+
return {"attributes": self.attributes, "level1_attr": self.level1_attr}
16+
17+
@classmethod
18+
def __from_data__(cls, data):
19+
obj = cls(attributes=data.get("attributes"))
20+
obj.level1_attr = data.get("level1_attr", "")
21+
return obj
22+
23+
24+
@pytest.fixture
25+
def level2():
26+
# Level2 is a custom class that is not available outside of this local scope
27+
class Level2(Level1):
28+
def __init__(self, attributes=None, name=None):
29+
super(Level2, self).__init__(attributes=attributes, name=name)
30+
self.level2_attr = "level2"
31+
32+
@property
33+
def __data__(self):
34+
data = super(Level2, self).__data__
35+
data["level2_attr"] = self.level2_attr
36+
return data
37+
38+
@classmethod
39+
def __from_data__(cls, data):
40+
obj = super(Level2, cls).__from_data__(data)
41+
obj.level2_attr = data.get("level2_attr", "")
42+
return obj
43+
44+
# return an instance of Level2
45+
return Level2(name="test")
46+
47+
48+
@pytest.fixture
49+
def level3():
50+
# Level2 and Level3 are custom classes that are not available outside of this local scope
51+
class Level2(Level1):
52+
def __init__(self, attributes=None, name=None):
53+
super(Level2, self).__init__(attributes=attributes, name=name)
54+
self.level2_attr = "level2"
55+
56+
@property
57+
def __data__(self):
58+
data = super(Level2, self).__data__
59+
data["level2_attr"] = self.level2_attr
60+
return data
61+
62+
@classmethod
63+
def __from_data__(cls, data):
64+
obj = super(Level2, cls).__from_data__(data)
65+
obj.level2_attr = data.get("level2_attr", "")
66+
return obj
67+
68+
class Level3(Level2):
69+
def __init__(self, attributes=None, name=None):
70+
super(Level3, self).__init__(attributes=attributes, name=name)
71+
self.level3_attr = "level3"
72+
73+
@property
74+
def __data__(self):
75+
data = super(Level3, self).__data__
76+
data["level3_attr"] = self.level3_attr
77+
return data
78+
79+
@classmethod
80+
def __from_data__(cls, data):
81+
obj = super(Level3, cls).__from_data__(data)
82+
obj.level3_attr = data.get("level3_attr", "")
83+
return obj
84+
85+
# return an instance of Level3
86+
return Level3(name="test")
87+
88+
89+
@pytest.fixture
90+
def custom_mesh():
91+
class CustomMesh(Mesh):
92+
def __init__(self, *args, **kwargs):
93+
super(CustomMesh, self).__init__(*args, **kwargs)
94+
self.custom_mesh_attr = "custom_mesh"
95+
96+
@property
97+
def __data__(self):
98+
data = super(CustomMesh, self).__data__
99+
data["custom_mesh_attr"] = self.custom_mesh_attr
100+
return data
101+
102+
@classmethod
103+
def __from_data__(cls, data):
104+
obj = super(CustomMesh, cls).__from_data__(data)
105+
obj.custom_mesh_attr = data.get("custom_mesh_attr", "")
106+
return obj
107+
108+
return CustomMesh(name="test")
109+
110+
111+
def test_inheritance_fallback(level2):
112+
if compas.IPY:
113+
# IronPython is not able to deserialize a class that is defined in a local scope like Level1.
114+
# We skip this tests for IronPython.
115+
return
116+
117+
assert level2.__jsondump__()["dtype"] == "test_datastructure/Level2"
118+
# Level2 should serialize Level1 into the inheritance
119+
assert level2.__jsondump__()["inheritance"] == ["test_datastructure/Level1"]
120+
121+
dumped = json_dumps(level2)
122+
loaded = json_loads(dumped)
123+
124+
# The loaded object should be deserialized as the closes available class: Level1
125+
assert loaded.__class__ == Level1
126+
assert loaded.__jsondump__()["dtype"] == "test_datastructure/Level1"
127+
assert loaded.__jsondump__()["inheritance"] == []
128+
129+
# level1 attributes should still be available
130+
assert loaded.level1_attr == "level1"
131+
# Meanwhile, level2 attributes will be discarded
132+
assert not hasattr(loaded, "level2_attr")
133+
134+
135+
def test_inheritance_fallback_multi_level(level3):
136+
if compas.IPY:
137+
# IronPython is not able to deserialize a class that is defined in a local scope like Level1.
138+
# We skip this tests for IronPython.
139+
return
140+
141+
assert level3.__jsondump__()["dtype"] == "test_datastructure/Level3"
142+
# Level3 should serialize Level2 and Level1 into the inheritance
143+
assert level3.__jsondump__()["inheritance"] == ["test_datastructure/Level2", "test_datastructure/Level1"]
144+
145+
dumped = json_dumps(level3)
146+
loaded = json_loads(dumped)
147+
148+
# The loaded object should be deserialized as the closes available class: Level1
149+
assert loaded.__class__ == Level1
150+
assert loaded.__jsondump__()["dtype"] == "test_datastructure/Level1"
151+
assert loaded.__jsondump__()["inheritance"] == []
152+
153+
# level1 attributes should still be available
154+
assert loaded.level1_attr == "level1"
155+
156+
# level2 and 3 attributes will be discarded
157+
assert not hasattr(loaded, "level2_attr")
158+
assert not hasattr(loaded, "level3_attr")
159+
160+
161+
def test_custom_mesh(custom_mesh):
162+
# This test should pass both Python and IronPython
163+
assert custom_mesh.__jsondump__()["dtype"].endswith("CustomMesh")
164+
assert custom_mesh.__jsondump__()["inheritance"] == ["compas.datastructures/Mesh"]
165+
assert custom_mesh.__jsondump__()["data"]["custom_mesh_attr"] == "custom_mesh"
166+
167+
dumped = json_dumps(custom_mesh)
168+
loaded = json_loads(dumped)
169+
170+
assert loaded.__class__ == Mesh
171+
assert loaded.__jsondump__()["dtype"] == "compas.datastructures/Mesh"
172+
assert loaded.__jsondump__()["inheritance"] == []
173+
assert not hasattr(loaded, "custom_mesh_attr")

0 commit comments

Comments
 (0)