diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f7c5f123942a22cc7de63424bd7fb7ea597569..8c9ed3081d083c1a78c14b590cb4b0e716478e71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ## ### Added ### -- Added location argument to `src/caosdb/utils/checkFileSystemConsistency.py` -- Entity getters: `get_entity_by_<name/id/path>` -- Cached versions of entity getters and of `execute_query` (`cached_query`) + +* `Entity.remove_value_from_property` function that removes a given value from a + property and optionally removes the property if it is empty afterwards. ### Changed ### @@ -19,12 +19,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed ### ### Fixed ### -- Fixed `src/caosdb/utils/checkFileSystemConsistency.py` ### Security ### ### Documentation ### +## [0.12.0] - 2023-06-02 ## + +### Added ### + +- Added location argument to `src/caosdb/utils/checkFileSystemConsistency.py` +- Entity getters: `get_entity_by_<name/id/path>` +- Cached versions of entity getters and of `execute_query` (`cached_query`) + +### Deprecated ### + +- getOriginUrlIn, getDiffIn, getBranchIn, getCommitIn (formerly apiutils) have been + moved to caosdb.utils.git_utils + +### Fixed ### + +- Fixed `src/caosdb/utils/checkFileSystemConsistency.py` + +### Documentation ### + * [#83](https://gitlab.com/caosdb/caosdb-pylib/-/issues/83) - Improved documentation on adding REFERENCE properties, both in the docstring of `Entity.add_property` and in the data-insertion tutorial. @@ -32,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.11.2] - 2023-03-14 ## ### Fixed ### -- root logger is no longer used to create warnings. Fixes undesired output in +- root logger is no longer used to create warnings. Fixes undesired output in stderr ## [0.11.1] - 2023-03-07 ## diff --git a/CITATION.cff b/CITATION.cff index 910e40a2193d527fc8e4eb68c4ca6b10a28d3630..d9126aae6483459f8c8f248ed6a4fdf859f24e45 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -20,6 +20,6 @@ authors: given-names: Stefan orcid: https://orcid.org/0000-0001-7214-8125 title: CaosDB - Pylib -version: 0.11.1 +version: 0.12.0 doi: 10.3390/data4020083 -date-released: 2022-11-14 \ No newline at end of file +date-released: 2023-06-02 \ No newline at end of file diff --git a/setup.py b/setup.py index a8b948c1c9097ea49d391d0fe0747290d21be4a6..8fdf3b1c63322ec48af398d1dcb1c4028355d473 100755 --- a/setup.py +++ b/setup.py @@ -47,8 +47,8 @@ from setuptools import find_packages, setup ISRELEASED = False MAJOR = 0 -MINOR = 11 -MICRO = 3 +MINOR = 12 +MICRO = 1 # Do not tag as pre-release until this commit # https://github.com/pypa/packaging/pull/515 # has made it into a release. Probably we should wait for pypa/packaging>=21.4 diff --git a/src/caosdb/apiutils.py b/src/caosdb/apiutils.py index a5a936c556dd065b56b60ff690baf9a1ce19a583..a46e30375b924d358448e73aece61562c36c700b 100644 --- a/src/caosdb/apiutils.py +++ b/src/caosdb/apiutils.py @@ -27,21 +27,20 @@ """ import logging -import sys -import tempfile import warnings from collections.abc import Iterable -from subprocess import call -from typing import Optional, Any, Dict, List +from typing import Any, Dict, List -from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, - REFERENCE, TEXT, is_reference) -from caosdb.common.models import (Container, Entity, File, Property, Query, +from caosdb.common.datatype import is_reference +from caosdb.common.models import (Container, Entity, File, Property, Record, RecordType, execute_query, - get_config, SPECIAL_ATTRIBUTES) + SPECIAL_ATTRIBUTES) from caosdb.exceptions import CaosDBException +from caosdb.utils.git_utils import (get_origin_url_in, get_diff_in, + get_branch_in, get_commit_in) + logger = logging.getLogger(__name__) @@ -148,51 +147,35 @@ def retrieve_entities_with_ids(entities): def getOriginUrlIn(folder): - """return the Fetch URL of the git repository in the given folder.""" - with tempfile.NamedTemporaryFile(delete=False, mode="w") as t: - call(["git", "remote", "show", "origin"], stdout=t, cwd=folder) - with open(t.name, "r") as t: - urlString = "Fetch URL:" - - for line in t.readlines(): - if urlString in line: - return line[line.find(urlString) + len(urlString):].strip() - - return None + warnings.warn(""" + This function is deprecated and will be removed with the next release. + Please use the caosdb.utils.git_utils.get_origin_url_in instead.""", + DeprecationWarning) + return get_origin_url_in(folder) def getDiffIn(folder, save_dir=None): - """returns the name of a file where the out put of "git diff" in the given - folder is stored.""" - with tempfile.NamedTemporaryFile(delete=False, mode="w", dir=save_dir) as t: - call(["git", "diff"], stdout=t, cwd=folder) - - return t.name + warnings.warn(""" + This function is deprecated and will be removed with the next release. + Please use the caosdb.utils.git_utils.get_diff_in instead.""", + DeprecationWarning) + return get_diff_in(folder, save_dir) def getBranchIn(folder): - """returns the current branch of the git repository in the given folder. - - The command "git branch" is called in the given folder and the - output is returned - """ - with tempfile.NamedTemporaryFile(delete=False, mode="w") as t: - call(["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout=t, cwd=folder) - with open(t.name, "r") as t: - return t.readline().strip() + warnings.warn(""" + This function is deprecated and will be removed with the next release. + Please use the caosdb.utils.git_utils.get_branch_in instead.""", + DeprecationWarning) + return get_branch_in(folder) def getCommitIn(folder): - """returns the commit hash in of the git repository in the given folder. - - The command "git log -1 --format=%h" is called in the given folder - and the output is returned - """ - - with tempfile.NamedTemporaryFile(delete=False, mode="w") as t: - call(["git", "log", "-1", "--format=%h"], stdout=t, cwd=folder) - with open(t.name, "r") as t: - return t.readline().strip() + warnings.warn(""" + This function is deprecated and will be removed with the next release. + Please use the caosdb.utils.git_utils.get_commit_in instead.""", + DeprecationWarning) + return get_commit_in(folder) def compare_entities(old_entity: Entity, new_entity: Entity, compare_referenced_records: bool = False): diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index 40434b9dab03e136bfdaab42615d005f9a5bb17a..8f7a73d85bba41fc2c07c8f675ac659180d29194 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -1,12 +1,11 @@ # -*- coding: 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-2022 Indiscale GmbH <info@indiscale.com> -# Copyright (C) 2020 Florian Spreckelsen <f.spreckelsen@indiscale.com> +# Copyright (C) 2020-2023 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2020-2023 Florian Spreckelsen <f.spreckelsen@indiscale.com> # Copyright (C) 2020-2022 Timm Fitschen <t.fitschen@indiscale.com> # # This program is free software: you can redistribute it and/or modify @@ -22,7 +21,6 @@ # 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 # """ @@ -47,6 +45,7 @@ from os import listdir from os.path import isdir from random import randint from tempfile import NamedTemporaryFile +from typing import Any, Optional from warnings import warn from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, INTEGER, TEXT, @@ -453,6 +452,66 @@ class Entity: return self + def remove_value_from_property(self, property_name: str, value: Any, + remove_if_empty_afterwards: Optional[bool] = True): + """Remove a value from a property given by name. + + Do nothing if this entity does not have a property of this + ``property_name`` or if the property value is different of the given + ``value``. By default, the property is removed from this entity if it + becomes empty (i.e., value=None) through removal of the value. This + behavior can be changed by setting ``remove_if_empty_afterwards`` to + ``False`` in which case the property remains. + + Notes + ----- + If the property value is a list and the value to be removed occurs more + than once in this list, only its first occurrance is deleted (similar + to the behavior of Python's ``list.remove()``.) + + If the property was empty (prop.value == None) before, the property is + not removed afterwards even if ``remove_if_empty_afterwards`` is set to + ``True``. Rationale: the property being empty is not an effect of + calling this function. + + Parameters + ---------- + property_name : str + Name of the property from which the ``value`` will be removed. + + value + Value that is to be removed. + + remove_if_empty_afterwards : bool, optional + Whether the property shall be removed from this entity if it is + emptied by removing the ``value``. Default is ``True``. + + Returns + ------- + self + This entity. + + """ + + if self.get_property(property_name) is None: + return self + if self.get_property(property_name).value is None: + remove_if_empty_afterwards = False + empty_afterwards = False + if isinstance(self.get_property(property_name).value, list): + if value in self.get_property(property_name).value: + self.get_property(property_name).value.remove(value) + if self.get_property(property_name).value == []: + self.get_property(property_name).value = None + empty_afterwards = True + elif self.get_property(property_name).value == value: + self.get_property(property_name).value = None + empty_afterwards = True + if remove_if_empty_afterwards and empty_afterwards: + self.remove_property(property_name) + + return self + def remove_parent(self, parent): self.parents.remove(parent) diff --git a/src/caosdb/high_level_api.py b/src/caosdb/high_level_api.py index 427a095a4bafc0c372b0169298f2980dbd902c49..005a20bbba26fd5bee16eac612bd8ebe81f1294a 100644 --- a/src/caosdb/high_level_api.py +++ b/src/caosdb/high_level_api.py @@ -629,18 +629,20 @@ class CaosDBPythonEntity(object): else: entity = CaosDBPythonRecord() - for parent in serialization["parents"]: - if "unresolved" in parent: - id = None - name = None - if "id" in parent: - id = parent["id"] - if "name" in parent: - name = parent["name"] - entity.add_parent(CaosDBPythonUnresolvedParent( - id=id, name=name)) - else: - raise NotImplementedError() + if "parents" in serialization: + for parent in serialization["parents"]: + if "unresolved" in parent: + id = None + name = None + if "id" in parent: + id = parent["id"] + if "name" in parent: + name = parent["name"] + entity.add_parent(CaosDBPythonUnresolvedParent( + id=id, name=name)) + else: + raise NotImplementedError( + "Currently, only unresolved parents can be deserialized.") for baseprop in ("name", "id", "description", "version"): if baseprop in serialization: @@ -673,7 +675,8 @@ class CaosDBPythonEntity(object): if f.name in metadata: propmeta.__setattr__(f.name, metadata[f.name]) else: - raise NotImplementedError() + pass + # raise NotImplementedError() return entity diff --git a/src/caosdb/utils/git_utils.py b/src/caosdb/utils/git_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..7a58272a3bef1930f75a1e08364349388e2bb89f --- /dev/null +++ b/src/caosdb/utils/git_utils.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# +# 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 Timm Fitschen <t.fitschen@indiscale.com> +# Copyright (C) 2020-2022 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 +# +"""git-utils: Some functions for retrieving information about git repositories. + +""" + +import logging +import tempfile + +from subprocess import call + +logger = logging.getLogger(__name__) + + +def get_origin_url_in(folder: str): + """return the Fetch URL of the git repository in the given folder.""" + with tempfile.NamedTemporaryFile(delete=False, mode="w") as t: + call(["git", "remote", "show", "origin"], stdout=t, cwd=folder) + with open(t.name, "r") as t: + urlString = "Fetch URL:" + + for line in t.readlines(): + if urlString in line: + return line[line.find(urlString) + len(urlString):].strip() + + return None + + +def get_diff_in(folder: str, save_dir=None): + """returns the name of a file where the out put of "git diff" in the given + folder is stored.""" + with tempfile.NamedTemporaryFile(delete=False, mode="w", dir=save_dir) as t: + call(["git", "diff"], stdout=t, cwd=folder) + + return t.name + + +def get_branch_in(folder: str): + """returns the current branch of the git repository in the given folder. + + The command "git branch" is called in the given folder and the + output is returned + """ + with tempfile.NamedTemporaryFile(delete=False, mode="w") as t: + call(["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout=t, cwd=folder) + with open(t.name, "r") as t: + return t.readline().strip() + + +def get_commit_in(folder: str): + """returns the commit hash in of the git repository in the given folder. + + The command "git log -1 --format=%h" is called in the given folder + and the output is returned + """ + + with tempfile.NamedTemporaryFile(delete=False, mode="w") as t: + call(["git", "log", "-1", "--format=%h"], stdout=t, cwd=folder) + with open(t.name, "r") as t: + return t.readline().strip() diff --git a/src/doc/conf.py b/src/doc/conf.py index 819ef61d7fb02e752b4a73a86644d1602bbf188a..0fa5de575f5424e267cad8ecc193cca8230faa8b 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -29,10 +29,10 @@ copyright = '2023, IndiScale GmbH' author = 'Daniel Hornung' # The short X.Y version -version = '0.11.3' +version = '0.12.1' # The full version, including alpha/beta/rc tags # release = '0.5.2-rc2' -release = '0.11.3-dev' +release = '0.12.1-dev' # -- General configuration --------------------------------------------------- diff --git a/unittests/docker/Dockerfile b/unittests/docker/Dockerfile index 06f9d6c830068a2c1c85caef79c64f899eaefb33..7c84050b0a55ae6e1e8f2e2583f894a69f691193 100644 --- a/unittests/docker/Dockerfile +++ b/unittests/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:latest +FROM debian:bullseye # Use local package repository COPY sources.list.local /etc/apt/ RUN mv /etc/apt/sources.list /etc/apt/sources.list.orig diff --git a/unittests/test_property.py b/unittests/test_property.py index 7c756117765e510587c00d818e39fb3945d44c53..84f89b5a959192d7831e1bb3eab3a441912afe7e 100644 --- a/unittests/test_property.py +++ b/unittests/test_property.py @@ -1,11 +1,11 @@ # -*- 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 - 2023 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2023 Florian Spreckelsen <f.spreckelsen@indiscale.com> # Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> # # This program is free software: you can redistribute it and/or modify @@ -21,8 +21,6 @@ # 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 -# """Tests for the Property class.""" import os @@ -138,3 +136,87 @@ def test_is_reference(): # restore retrieve function with original Entity.retrieve = real_retrieve + + +def test_remove_value_from_property(): + + rec = Record() + names_values_dtypes = [ + ("testListProp1", [1, 2, 3], db.LIST(db.INTEGER)), + ("testListProp2", ["a", "b", "a"], db.LIST(db.TEXT)), + ("testScalarProp1", "bla", db.TEXT), + ("testScalarProp2", False, db.BOOLEAN), + ("testEmptyProp", None, db.REFERENCE), + ("testNoneListProp", [None, None], db.LIST(db.REFERENCE)), + ] + for name, value, dtype in names_values_dtypes: + rec.add_property(name=name, value=value, datatype=dtype) + + # property doesn't exist, so do nothing + returned = rec.remove_value_from_property("nonexisting", "some_value") + assert returned is rec + for name, value, dtype in names_values_dtypes: + assert rec.get_property(name).value == value + assert rec.get_property(name).datatype == dtype + + # value doesn't exist so nothing changes either + rec.remove_value_from_property("testListProp1", 0) + assert rec.get_property("testListProp1").value == [1, 2, 3] + assert rec.get_property("testListProp1").datatype == db.LIST(db.INTEGER) + + returned = rec.remove_value_from_property("testScalarProp2", True) + assert returned is rec + assert rec.get_property("testScalarProp2").value is False + assert rec.get_property("testScalarProp2").datatype == db.BOOLEAN + + # Simple removals from lists without emptying them + rec.remove_value_from_property("testListProp1", 1) + assert rec.get_property("testListProp1").value == [2, 3] + + rec.remove_value_from_property("testListProp1", 2) + assert rec.get_property("testListProp1").value == [3] + + # similarly to Python's `list.remove()`, only remove first occurrance + rec.remove_value_from_property("testListProp2", "a") + assert rec.get_property("testListProp2").value == ["b", "a"] + + # default is to remove an empty property: + rec.remove_value_from_property("testListProp1", 3) + assert rec.get_property("testListProp1") is None + + rec.remove_value_from_property("testScalarProp1", "bla") + assert rec.get_property("testScalarProp1") is None + + # don't remove if `remove_if_empty_afterwards=False` + rec.remove_value_from_property("testListProp2", "b") + rec.remove_value_from_property("testListProp2", "a", remove_if_empty_afterwards=False) + assert rec.get_property("testListProp2") is not None + assert rec.get_property("testListProp2").value is None + assert rec.get_property("testListProp2").datatype == db.LIST(db.TEXT) + + rec.remove_value_from_property("testScalarProp2", False, remove_if_empty_afterwards=False) + assert rec.get_property("testScalarProp2") is not None + assert rec.get_property("testScalarProp2").value is None + assert rec.get_property("testScalarProp2").datatype == db.BOOLEAN + + # Special case of an already empty property: It is not empty because a value + # was removed by `remove_value_from_property` but never had a value in the + # first place. So even `remove_if_empty_afterwards=True` should not lead to + # its removal. + rec.remove_value_from_property("testEmptyProp", 1234, remove_if_empty_afterwards=True) + assert rec.get_property("testEmptyProp") is not None + assert rec.get_property("testEmptyProp").value is None + assert rec.get_property("testEmptyProp").datatype == db.REFERENCE + + # Corner case of corner case: remove with `value=None` and + # `remove_if_empty_afterwards=True` keeps the empty property. + rec.remove_value_from_property("testEmptyProp", None, remove_if_empty_afterwards=True) + assert rec.get_property("testEmptyProp") is not None + assert rec.get_property("testEmptyProp").value is None + assert rec.get_property("testEmptyProp").datatype == db.REFERENCE + + # Remove `None` from list `[None, None]` + rec.remove_value_from_property("testNoneListProp", None, remove_if_empty_afterwards=True) + assert rec.get_property("testNoneListProp") is not None + assert rec.get_property("testNoneListProp").value == [None] + assert rec.get_property("testNoneListProp").datatype == db.LIST(db.REFERENCE)