Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add storage and serialisation of general attributes #1247

Merged
merged 17 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased

### Added

* Added `viewerinstance` in `compas.scene.Scene` to support viewers context detection.
* Added `compas_rhino8` as starting point for Rhino8 support.
* Added `compas.scene.SceneObjectNode`.
Expand All @@ -33,19 +34,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Added `show_edges` flag to `compas.scene.VolMeshObject`.
* Added `show_faces` flag to `compas.scene.VolMeshObject`.
* Added `show_cells` flag to `compas.scene.VolMeshObject`.
* Added `compas.data.Data.to_jsonstring` and `compas.data.Data.from_jsonstring`.
* Added `compas.data.Data.attributes`.

### Changed

* Changed the `__str__` of `compas.geometry.Point` and `compas.geometry.Vector` to use a limited number of decimals (determined by `Tolerance.PRECISION`). Note: `__repr__` will instead maintain full precision.
* In pull requests, `docs` Workflow are now only triggered on review approval.
* The `draw` implementations of `compas.scene.SceneObject` will now always use the `worldtransformation` of the `SceneObject`.
* Changed `docs` Workflow to only be triggered on review approval in pull requests.
* Changed `draw` implementations of `compas.scene.SceneObject` to always use the `worldtransformation` of the `SceneObject`.
* Fixed typo in name `Rhino.Geometry.MeshingParameters` in `compas_rhino.geometry.RhinoBrep.to_meshes()`.
* Fixed `TypeErrorException` when serializing a `Mesh` which has been converted from Rhino.
* Fixed color conversions in `compas_rhion.conversions.mesh_to_compas`.
* Changed `compas.data.Data.name` to be stored in `compas.data.Data.attributes`.
* Changed `compas.data.Data.__jsondump__` to include `compas.data.Data.attributes` if the dict is not empty.
* Changed `compas.data.Data.__jsonload__` to update `compas.data.Data.attributes` if the attribute dict is provided.

### Removed

* Removed `compas_rhino.forms`. Forms will be moved to `compas_ui`.
* Removed `compas.datastructures.Datastructure.attributes` and `compas.datastructures.Datastructure.name` (moved to `compas.data.Data`).

## [2.0.0-beta.1] 2023-12-20

Expand Down
75 changes: 67 additions & 8 deletions src/compas/data/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class Data(object):

def __init__(self, name=None):
self._guid = None
self._name = None
self.attributes = {}
tomvanmele marked this conversation as resolved.
Show resolved Hide resolved
if name:
self.name = name

Expand All @@ -95,13 +95,15 @@ def __jsondump__(self, minimal=False):
"dtype": self.dtype,
"data": self.data,
}
if self.attributes:
state["attrs"] = self.attributes
if minimal:
return state
state["guid"] = str(self.guid)
return state

@classmethod
def __jsonload__(cls, data, guid=None):
def __jsonload__(cls, data, guid=None, attrs=None):
"""Construct an object of this type from the provided data to support COMPAS JSON serialization.

Parameters
Expand All @@ -110,6 +112,8 @@ def __jsonload__(cls, data, guid=None):
The raw Python data representing the object.
guid : str, optional
The GUID of the object.
attrs : dict, optional
The additional attributes of the object.

Returns
-------
Expand All @@ -119,6 +123,8 @@ def __jsonload__(cls, data, guid=None):
obj = cls.from_data(data)
if guid is not None:
obj._guid = UUID(guid)
if attrs is not None:
obj.attributes.update(attrs)
return obj

def __getstate__(self):
Expand All @@ -130,6 +136,9 @@ def __setstate__(self, state):
self.__dict__.update(state["__dict__"])
if "guid" in state:
self._guid = UUID(state["guid"])
# could be that this is already taken care of by the first line
if "attrs" in state:
self.attributes.update(state["attrs"])

@property
def dtype(self):
Expand Down Expand Up @@ -161,13 +170,11 @@ def guid(self):

@property
def name(self):
if not self._name:
self._name = self.__class__.__name__
return self._name
return self.attributes.get("name") or self.__class__.__name__

@name.setter
def name(self, name):
self._name = name
self.attributes["name"] = name

@classmethod
def from_data(cls, data): # type: (dict) -> Data
Expand Down Expand Up @@ -211,8 +218,16 @@ def from_json(cls, filepath): # type: (...) -> Data
:class:`compas.data.Data`
An instance of this object type if the data contained in the file has the correct schema.

Raises
------
TypeError
If the data in the file is not a :class:`compas.data.Data`.

"""
return compas.json_load(filepath)
data = compas.json_load(filepath)
if not isinstance(data, cls):
raise TypeError("The data in the file is not a {}.".format(cls))
return data

def to_json(self, filepath, pretty=False):
"""Convert an object to its native data representation and save it to a JSON file.
Expand All @@ -228,6 +243,48 @@ def to_json(self, filepath, pretty=False):
"""
compas.json_dump(self, filepath, pretty=pretty)

@classmethod
def from_jsonstring(cls, string): # type: (...) -> Data
"""Construct an object of this type from a JSON string.

Parameters
----------
string : str
The JSON string.

Returns
-------
:class:`compas.data.Data`
An instance of this object type if the data contained in the string has the correct schema.

Raises
------
TypeError
If the data in the string is not a :class:`compas.data.Data`.

"""
data = compas.json_loads(string)
if not isinstance(data, cls):
raise TypeError("The data in the string is not a {}.".format(cls))
return data

def to_jsonstring(self, pretty=False):
"""Convert an object to its native data representation and save it to a JSON string.

Parameters
----------
pretty : bool, optional
If True, the JSON string will be pretty printed.
Defaults to False.

Returns
-------
str
The JSON string.

"""
return compas.json_dumps(self, pretty=pretty)

def copy(self, cls=None): # type: (...) -> D
"""Make an independent copy of the data object.

Expand All @@ -245,7 +302,9 @@ def copy(self, cls=None): # type: (...) -> D
"""
if not cls:
cls = type(self)
return cls.from_data(deepcopy(self.data)) # type: ignore
obj = cls.from_data(deepcopy(self.data))
obj.attributes = deepcopy(self.attributes)
return obj # type: ignore

def sha256(self, as_string=False):
"""Compute a hash of the data for comparison during version control using the sha256 algorithm.
Expand Down
3 changes: 2 additions & 1 deletion src/compas/data/encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,12 @@ def object_hook(self, o):

data = o["data"]
guid = o.get("guid")
attrs = o.get("attrs")

# Kick-off from_data from a rebuilt Python dictionary instead of the C# data type
if IDictionary and isinstance(o, IDictionary[str, object]):
data = {key: data[key] for key in data.Keys}

obj = cls.__jsonload__(data, guid)
obj = cls.__jsonload__(data, guid, attrs)

return obj
11 changes: 1 addition & 10 deletions src/compas/datastructures/datastructure.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,5 @@
class Datastructure(Data):
"""Base class for all data structures."""

def __init__(self, name=None, **kwargs):
def __init__(self, **kwargs):
super(Datastructure, self).__init__(**kwargs)
tomvanmele marked this conversation as resolved.
Show resolved Hide resolved
self.attributes = {"name": name or self.__class__.__name__}

@property
def name(self):
return self.attributes.get("name") or self.__class__.__name__

@name.setter
def name(self, value):
self.attributes["name"] = value
26 changes: 8 additions & 18 deletions src/compas/datastructures/tree/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ class TreeNode(Data):

Attributes
----------
name : str
The name of the datastructure.
attributes : dict[str, Any]
User-defined attributes of the datastructure.
parent : :class:`compas.datastructures.TreeNode`
The parent node of the tree node.
children : list[:class:`compas.datastructures.TreeNode`]
Expand All @@ -47,16 +43,16 @@ class TreeNode(Data):
"type": "object",
"$recursiveAnchor": True,
"properties": {
"name": {"type": "string"},
"attributes": {"type": "object"},
"children": {"type": "array", "items": {"$recursiveRef": "#"}},
},
"required": ["name", "attributes", "children"],
"required": ["attributes", "children"],
}

def __init__(self, name=None, attributes=None):
super(TreeNode, self).__init__(name=name)
self.attributes = attributes or {}
if attributes:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it not be

def __init__(self, **kwargs):
    super(TreeNode, self).__init__(**kwargs)
    self._parent = None
    ...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and the same for the tree itself?
and should we not also remove the attributes from the data dict?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it not be

def __init__(self, **kwargs):
    super(TreeNode, self).__init__(**kwargs)
    self._parent = None
    ...

right, indeed! updated now

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and the same for the tree itself? and should we not also remove the attributes from the data dict?

You mean like this?

@property
    def data(self):
        return {
            # "attributes": self.attributes,
            "children": [child.data for child in self.children],
        }

We would lost track of name then, unless we put it back explicitly.
At least for TreeNode isn't it nice to keep the whole attributes dict so there is a bit more flexibility?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for the tree node there are two options

  1. keep the attributes dict in the data dict and convert the node explicitly to/from data
  2. don't include the attributes dict in the data dict and let the encoder/decoder handle to/from (then the attributes are included implicitly)

the file size of 1. is much smaller and is similar to what happens to nodes in a graph and vertices in a mesh. however, there the behaviour is not so explicit since the nodes and vertices are not separate objects.

if a tree node is not meant to exist on its own (like graph nodes and mesh vertices), perhaps we should make sure that they cannot be converted to/from data independently?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm more in favor of option.1. imo, the node can be create independently but there is no need to serialise them outside context of tree. They also don't need guid, because their structural location in the tree makes them naturally unique. Maybe it should even not inherant from Data at all... Well this is distracting the purpose of this PR, shall we keep it for now and open another PR for it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm.... actually... option 2 is also not bad........ well let's have this discussion in another PR...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently everything works, so we can merge this and continue the conversation later. but we need to make sure the behaviour is consistent everywhere and 100% transparent.

@gonzalocasas @chenkasirer opinions?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm.... actually... option 2 is also not bad........ well let's have this discussion in another PR...

i also prefer option 1 btw

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently everything works, so we can merge this and continue the conversation later. but we need to make sure the behaviour is consistent everywhere and 100% transparent.

@gonzalocasas @chenkasirer opinions?

yes agree

self.attributes.update(attributes)
self._parent = None
self._children = []
self._tree = None
Expand Down Expand Up @@ -94,14 +90,13 @@ def tree(self):
@property
def data(self):
return {
"name": self.name,
"attributes": self.attributes,
"children": [child.data for child in self.children],
}

@classmethod
def from_data(cls, data):
node = cls(data["name"], data["attributes"])
node = cls(attributes=data["attributes"])
for child in data["children"]:
node.add(cls.from_data(child))
return node
Expand Down Expand Up @@ -220,10 +215,6 @@ class Tree(Datastructure):

Attributes
----------
name : str
The name of the datastructure.
attributes : dict[str, Any]
User-defined attributes of the datastructure.
root : :class:`compas.datastructures.TreeNode`
The root node of the tree.
nodes : generator[:class:`compas.datastructures.TreeNode`]
Expand Down Expand Up @@ -256,29 +247,28 @@ class Tree(Datastructure):
DATASCHEMA = {
"type": "object",
"properties": {
"name": {"type": "string"},
"root": TreeNode.DATASCHEMA,
"attributes": {"type": "object"},
},
"required": ["name", "root", "attributes"],
"required": ["root", "attributes"],
}

def __init__(self, name=None, attributes=None):
super(Tree, self).__init__(name=name)
self.attributes.update(attributes or {})
if attributes:
self.attributes.update(attributes)
self._root = None

@property
def data(self):
return {
"name": self.name,
"root": self.root.data,
"attributes": self.attributes,
}

@classmethod
def from_data(cls, data):
tree = cls(data["name"], data["attributes"])
tree = cls(attributes=data["attributes"])
root = TreeNode.from_data(data["root"])
tree.add(root)
return tree
Expand Down
Loading