diff --git a/CHANGELOG.md b/CHANGELOG.md index f6465f2f908184e5625716bcf66cb2614137ac8a..47a207b3eb7842e12c10349ebe57de7c7540325b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * New exceptions `HTTPAuthorizationException` and `ResourceNotFoundException` for HTTP 403 and 404 errors, respectively. +* Versioning support (experimental). ### Changed ### @@ -34,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed ### +* deepcopy of `_Messages` objects + ### Security ### ## [0.4.0] - 2020-07-17## diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index 34fb6a400ab8259ef629667d228ca7ddd16075fb..5a8fa3973ac8732d9e00d11e151adf8ffec394ae 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -43,6 +43,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 @@ -92,6 +93,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 @@ -112,6 +114,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 @@ -814,6 +827,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: @@ -937,6 +953,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): @@ -1224,6 +1242,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): @@ -1274,6 +1293,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 @@ -1303,6 +1325,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")) @@ -2030,6 +2054,9 @@ class _Messages(dict): else: raise TypeError( "('type', 'code'), ('type'), or 'type' expected.") + elif isinstance(key, _Messages._msg_key): + type = key._type # @ReservedAssignment + code = key._code else: type = key # @ReservedAssignment code = None @@ -2044,11 +2071,14 @@ class _Messages(dict): else: raise TypeError( "('description', 'body'), ('body'), or 'body' expected.") + if isinstance(value, Message): + body = value.body + description = value.description else: body = value description = None m = Message(type=type, code=code, description=description, body=body) - self.append(m) + dict.__setitem__(self, _Messages._msg_key(type, code), m) def __getitem__(self, key): if isinstance(key, tuple): @@ -2189,6 +2219,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 @@ -3835,6 +3866,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_message.py b/unittests/test_message.py new file mode 100644 index 0000000000000000000000000000000000000000..5e1003056c1b606a004b63bb7618e5e0474952bc --- /dev/null +++ b/unittests/test_message.py @@ -0,0 +1,118 @@ +# encoding: utf-8 +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2020 Florian Spreckelsen <f.spreckelsen@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 +# +import caosdb as db +from copy import deepcopy + + +def test_messages_dict_behavior(): + from caosdb.common.models import Message + from caosdb.common.models import _Messages + + msgs = _Messages() + + # create Message + msg = Message( + type="HelloWorld", + code=1, + description="Greeting the world", + body="Hello, world!") + + # append it to the _Messages + assert repr(msg) == '<HelloWorld code="1" description="Greeting the world">Hello, world!</HelloWorld>\n' + msgs.append(msg) + assert len(msgs) == 1 + + # use _Messages as list of Message objects + for m in msgs: + assert isinstance(m, Message) + + # remove it + msgs.remove(msg) + assert len(msgs) == 0 + + # ok append it again ... + msgs.append(msg) + assert len(msgs) == 1 + # get it back via get(...) and the key tuple (type, code) + assert id(msgs.get("HelloWorld", 1)) == id(msg) + + # delete Message via remove and the (type,code) tuple + msgs.remove("HelloWorld", 1) + assert msgs.get("HelloWorld", 1) is None + assert len(msgs) == 0 + + # short version of adding/setting/resetting a new Message + msgs["HelloWorld", 2] = "Greeting the world in German", "Hallo, Welt!" + assert len(msgs) == 1 + assert msgs["HelloWorld", 2] == ( + "Greeting the world in German", "Hallo, Welt!") + + msgs["HelloWorld", 2] = "Greeting the world in German", "Huhu, Welt!" + assert len(msgs) == 1 + assert msgs["HelloWorld", 2] == ( + "Greeting the world in German", "Huhu, Welt!") + del msgs["HelloWorld", 2] + assert msgs.get("HelloWorld", 2) is None + + # this Message has no code and no description (make easy things easy...) + msgs["HelloWorld"] = "Hello!" + assert msgs["HelloWorld"] == "Hello!" + + +def test_deepcopy(): + """Test whether deepcopy of _Messages objects doesn't mess up + contained Messages objects. + + """ + msgs = db.common.models._Messages() + msg = db.Message(type="bla", code=1234, description="desc", body="blabla") + msgs.append(msg) + msg_copy = deepcopy(msgs)[0] + + # make sure type is string-like (formerly caused problems) + assert hasattr(msg_copy.type, "lower") + assert msg_copy.type == msg.type + assert msg_copy.code == msg.code + assert msg_copy.description == msg.description + assert msg_copy.body == msg.body + + +def test_deepcopy_clear_server(): + + msgs = db.common.models._Messages() + msg = db.Message(type="bla", code=1234, description="desc", body="blabla") + err_msg = db.Message(type="Error", code=1357, description="error") + msgs.extend([msg, err_msg]) + copied_msgs = deepcopy(msgs) + + assert len(copied_msgs) == 2 + assert copied_msgs.get("Error", err_msg.code).code == err_msg.code + assert copied_msgs.get("bla", msg.code).code == msg.code + + # Only the error should be removed + copied_msgs.clear_server_messages() + assert len(copied_msgs) == 1 + assert copied_msgs[0].code == msg.code 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]