diff --git a/CHANGELOG.md b/CHANGELOG.md index 306d161f0ad91a8a7df2be341b2d79231626bbbf..ff8555cd4305c721599a381ad2cae872dfde0938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### +* Versioning support (experimental). + ### Changed ### ### Deprecated ### diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index be6dfdcfd8ce5c90929785a3649da9b745868861..7ef5df660af1578cd34838adf14815dd9f776771 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -42,6 +42,7 @@ from warnings import warn from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, LIST, REFERENCE, TEXT, is_reference) +from caosdb.common.versioning import Version from caosdb.common.utils import uuid, xml2str from caosdb.configuration import get_config from caosdb.connection.connection import get_connection @@ -88,6 +89,7 @@ class Entity(object): self._size = None self._upload = None self._wrapped_entity = None + self._version = None self._cuid = None self._flags = dict() self.__value = None @@ -108,6 +110,17 @@ class Entity(object): self.description = description self.id = id + @property + def version(self): + if self._version is not None or self._wrapped_entity is None: + return self._version + + return self._wrapped_entity.version + + @version.setter + def version(self, version): + self._version = version + @property def role(self): return self.__role @@ -810,6 +823,9 @@ class Entity(object): if self.description is not None: xml.set("description", str(self.description)) + if self.version is not None: + xml.append(self.version.to_xml()) + if self.value is not None: if isinstance(self.value, Entity): if self.value.id is not None: @@ -933,6 +949,8 @@ class Entity(object): entity.permissions = child elif isinstance(child, Message): entity.add_message(child) + elif isinstance(child, Version): + entity.version = child elif child is None or hasattr(child, "encode"): vals.append(child) elif isinstance(child, Entity): @@ -1220,6 +1238,7 @@ class QueryTemplate(): self.permissions = None self.is_valid = lambda: False self.is_deleted = lambda: False + self.version = None def retrieve(self, strict=True, raise_exception_on_error=True, unique=True, sync=True, flags=None): @@ -1270,6 +1289,9 @@ class QueryTemplate(): if self.description is not None: xml.set("description", self.description) + if self.version is not None: + xml.append(self.version.to_xml()) + if self.query is not None: queryElem = etree.Element("Query") queryElem.text = self.query @@ -1299,6 +1321,8 @@ class QueryTemplate(): q.messages.append(child) elif isinstance(child, ACL): q.acl = child + elif isinstance(child, Version): + q.version = child elif isinstance(child, Permissions): q.permissions = child q.id = int(xml.get("id")) @@ -2184,6 +2208,7 @@ def _basic_sync(e_local, e_remote): e_local.permissions = e_remote.permissions e_local.is_valid = e_remote.is_valid e_local.is_deleted = e_remote.is_deleted + e_local.version = e_remote.version if hasattr(e_remote, "query"): e_local.query = e_remote.query @@ -3817,6 +3842,8 @@ def _parse_single_xml_element(elem): Entity._from_xml(entity, elem) return entity + elif elem.tag.lower() == "version": + return Version.from_xml(elem) elif elem.tag.lower() == "emptystring": return "" elif elem.tag.lower() == "value": diff --git a/src/caosdb/common/versioning.py b/src/caosdb/common/versioning.py new file mode 100644 index 0000000000000000000000000000000000000000..bac99b73d5803b512ec6e6a9558acfd13a8881a5 --- /dev/null +++ b/src/caosdb/common/versioning.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +""" Versioning module for anything related to entity versions. + +Currently this module defines nothing but a single class, `Version`. +""" + +from __future__ import absolute_import +from caosdb.common.utils import xml2str +from lxml import etree + + +class Version(): + """The version of an entity. + + An entity version has a version id (string), a date (UTC timestamp), a + list of predecessors and a list of successors. + + Parameters + ---------- + id : str, optional + See attribute `id`. Default: None + date : str, optional + See attribute `data`. Default: None + predecessors : list of Version, optional + See attribute `predecessors`. Default: empty list. + successors : list of Version, optional + See attribute `successors`. Default: empty list. + + Attributes + ---------- + id : str + Version ID (not the entity's id). + date : str + UTC Timestamp of the version, i.e. the date and time when the entity of + this version has been inserted or modified. + predecessors : list of Version + Predecessors are the older entity versions which have been modified + into this version. Usually, there is only one predecessor. However, + this API allows for entities to be merged into one entity, which would + result in more than one predecessor. + successors : list of Version + Successors are newer versions of this entity. If there are successors, + this version is not the latest version of this entity. Usually, there + is only one successor. However, this API allows that a single entity + may co-exist in several versions (e.g. several proposals for the next + entity status). That would result in more than one successor. + """ + + # pylint: disable=redefined-builtin + def __init__(self, id=None, date=None, predecessors=None, successors=None): + self.id = id + self.date = date + self.predecessors = predecessors if predecessors is not None else [] + self.successors = successors if successors is not None else [] + + def to_xml(self, tag="Version"): + """Serialize this version to xml. + + The tag name is 'Version' per default. But since this method is called + recursively for the predecessors and successors as well, the tag name + can be configured. + + The resulting xml element contains attributes 'id' and 'date' and + 'Predecessor' and 'Successor' child elements. + + Parameters + ---------- + tag : str, optional + The name of the returned xml element. Defaults to 'Version'. + + Returns + ------- + xml : etree.Element + """ + xml = etree.Element(tag) + if self.id is not None: + xml.set("id", self.id) + if self.date is not None: + xml.set("date", self.date) + if self.predecessors is not None: + for p in self.predecessors: + xml.append(p.to_xml(tag="Predecessor")) + if self.successors is not None: + for s in self.successors: + xml.append(s.to_xml(tag="Successor")) + return xml + + def __str__(self): + """Return a stringified xml representation.""" + return self.__repr__() + + def __repr__(self): + """Return a stringified xml representation.""" + return xml2str(self.to_xml()) + + @staticmethod + def from_xml(xml): + """Parse a version object from a 'Version' xml element. + + Parameters + ---------- + xml : etree.Element + A 'Version' xml element, with 'id' and 'date' attributes and + 'Predecessor' and 'Successor' child elements. + + Returns + ------- + version : Version + a new version instance + """ + predecessors = [Version.from_xml(p) for p in xml if p.tag.lower() == "predecessor"] + successors = [Version.from_xml(s) for s in xml if s.tag.lower() == "successor"] + return Version(id=xml.get("id"), date=xml.get("date"), + predecessors=predecessors, successors=successors) + + def __hash__(self): + """Hash of the version instance. + + Also hashes the predecessors and successors. + """ + return (hash(self.id) + + hash(self.date) + + (Version._hash_list(self.predecessors) + if self.predecessors else 26335) + + (Version._hash_list(self.successors) + if self.successors else -23432)) + + @staticmethod + def _hash_list(_list): + """Hash a list by hashing each element and its index.""" + result = 12352 + for idx, val in enumerate(_list): + result += hash(val) + idx + return result + + @staticmethod + def _eq_list(this, that): + """List equality. + + List equality is defined as equality of each element, the order + and length. + """ + if len(this) != len(that): + return False + for v1, v2 in zip(this, that): + if v1 != v2: + return False + return True + + def __eq__(self, other): + """Equality of versions is defined by equality of id, date, and list + equality of the predecessors and successors.""" + return (self.id == other.id + and self.date == other.date + and Version._eq_list(self.predecessors, other.predecessors) + and Version._eq_list(self.successors, other.successors)) diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index df6fe52d5b6f7ceddea791167a2355d7b3bf2fc8..c560b5e3c7c424b762bc8381c7cc9f42617288d0 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -27,9 +27,9 @@ # A. Schlemmer, 02/2018 import caosdb as db -from caosdb.apiutils import apply_to_ids import pickle import tempfile +from caosdb.apiutils import apply_to_ids from .test_property import testrecord diff --git a/unittests/test_record.xml b/unittests/test_record.xml index 5567e59050fdbcd07ce9b13cdc640c7bccf6c165..e961bdc6b88eb8e62b92696b52d7ad6a2dbf8089 100644 --- a/unittests/test_record.xml +++ b/unittests/test_record.xml @@ -1,4 +1,5 @@ <Record id="171179"> + <Version id="version-str" date="2019-04-02T12:22:34.837UTC"/> <TransactionBenchmark></TransactionBenchmark> <Parent description="Experiment for the LEAP project." id="163454" name="LeapExperiment"/> <Property datatype="TEXT" description="A unique identifier for experiments" flag="inheritance:FIX" id="163414" importance="FIX" name="experimentId">KCdSYWJiaXQnLCAnZXhfdml2bycsICdzeW5jaHJvbml6YXRpb25fbWFwJywgTm9uZSwgJzIwMTctMDgtMTEnLCBOb25lLCBOb25lKQ==</Property> diff --git a/unittests/test_versioning.py b/unittests/test_versioning.py new file mode 100644 index 0000000000000000000000000000000000000000..d6009e79949c4b96ab0a6645eb6ec3cf28461441 --- /dev/null +++ b/unittests/test_versioning.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# + +from __future__ import absolute_import +from caosdb import Record +from caosdb.common.utils import xml2str +from caosdb.common.versioning import Version +from .test_property import testrecord + + +def test_constructor(): + v = Version(id="1234abcd", date="2020-01-01T20:15:00.000UTC", + predecessors=[Version(id="2345abdc", + date="2020-01-01T20:00:00.000UTC")], + successors=[Version(id="3465abdc", + date="2020-01-01T20:30:00.000UTC")]) + assert v.id == "1234abcd" + assert v.date == "2020-01-01T20:15:00.000UTC" + assert isinstance(v.predecessors, list) + assert isinstance(v.predecessors[0], Version) + assert isinstance(v.successors, list) + assert isinstance(v.successors[0], Version) + + return v + + +def test_to_xml(): + v = test_constructor() + xmlstr = xml2str(v.to_xml()) + assert xmlstr == ('<Version id="{i}" date="{d}">\n' + ' <Predecessor id="{pi}" date="{pd}"/>\n' + ' <Successor id="{si}" date="{sd}"/>\n' + '</Version>\n').format(i=v.id, d=v.date, + pi=v.predecessors[0].id, + pd=v.predecessors[0].date, + si=v.successors[0].id, + sd=v.successors[0].date) + + xmlstr2 = xml2str(v.to_xml(tag="OtherVersionTag")) + assert xmlstr2 == ('<OtherVersionTag id="{i}" date="{d}">\n' + ' <Predecessor id="{pi}" date="{pd}"/>\n' + ' <Successor id="{si}" date="{sd}"/>\n' + '</OtherVersionTag>\n' + ).format(i=v.id, d=v.date, pi=v.predecessors[0].id, + pd=v.predecessors[0].date, + si=v.successors[0].id, sd=v.successors[0].date) + + +def test_equality(): + v = test_constructor() + assert hash(v) == hash(v) + v2 = test_constructor() + assert hash(v) == hash(v2) + assert v == v2 + + v = Version() + v2 = Version() + assert hash(v) == hash(v2) + assert v == v2 + + v = Version(id="123") + v2 = Version(id="123") + v3 = Version(id="2345") + assert hash(v) == hash(v2) + assert v == v2 + assert hash(v) != hash(v3) + assert v != v3 + + v = Version(id="123", date="2345", predecessors=None) + v2 = Version(id="123", date="2345", predecessors=[]) + v3 = Version(id="123", date="Another date", predecessors=[]) + assert hash(v) == hash(v2) + assert v == v2 + assert hash(v) != hash(v3) + assert v != v3 + + v = Version(id="123", date="2345", predecessors=[Version(id="bla")]) + v2 = Version(id="123", date="2345", predecessors=[Version(id="bla")]) + v3 = Version(id="123", date="2345", predecessors=[Version(id="blub")]) + v4 = Version(id="123", date="2345", predecessors=[Version(id="bla"), + Version(id="bla")]) + assert hash(v) == hash(v2) + assert v == v2 + assert hash(v) != hash(v3) + assert v != v3 + assert hash(v) != hash(v4) + assert v != v4 + + v = Version(id="123", date="2345", predecessors=[Version(id="bla")], + successors=[Version(id="234")]) + v2 = Version(id="123", date="2345", predecessors=[Version(id="bla")], + successors=[Version(id="234")]) + v3 = Version(id="123", date="2345", predecessors=[Version(id="bla")], + successors=[Version(id="bluup")]) + assert hash(v) == hash(v2) + assert v == v2 + assert hash(v) != hash(v3) + assert v != v3 + + +def test_from_xml(): + v = test_constructor() + xml = v.to_xml() + + v2 = Version.from_xml(xml) + + assert hash(v) == hash(v2) + assert v == v2 + assert str(v) == str(v2) + + +def test_version_deserialization(): + assert testrecord.version == Version(id="version-str", + date="2019-04-02T12:22:34.837UTC") + + +def test_version_serialization(): + r = Record() + r.version = Version(id="test-version", date="asdfsadf") + + # <Record><Version id="test-version" date="asdfsadf"/></Record> + assert "test-version" == r.to_xml().xpath("/Record/Version/@id")[0] + assert "asdfsadf" == r.to_xml().xpath("/Record/Version/@date")[0]