diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ebfe9b56d0255c36e8552b86691dbf9da3cba4f..f989ba0266a4fda67ffb71bc9e900c21a20484ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.13.2] - 2023-12-15 + +### Fixed ### + +* [#113](https://gitlab.com/linkahead/linkahead-pylib/-/issues/113) Container could fail to delete when there were reference properties. +* HTTP status 431 (Headers too long) now also raises an URI too long exception. + ## [0.13.1] - 2023-10-11 ## ### Fixed ### diff --git a/CITATION.cff b/CITATION.cff index d5cafac5caee39399f21aee100cf4acf909cca55..b9f249ed501bd1c6dd217e56d7ea47748c1032dc 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.13.1 +version: 0.13.2 doi: 10.3390/data4020083 date-released: 2023-10-11 diff --git a/setup.py b/setup.py index 2b040aa0cdea75e68a005ac59f55124b0ebb144b..55e6ee154e2906494bece48c2f741e53e78aa2fe 100755 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ from setuptools import find_packages, setup ISRELEASED = True MAJOR = 0 MINOR = 13 -MICRO = 1 +MICRO = 2 # 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/doc/conf.py b/src/doc/conf.py index 68f970fd54e105eb6bcb18e1db1b162e74a972c4..84bdc6eac88b345e2b0dd04c5d1f9c413362746b 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.13.1' +version = '0.13.2' # The full version, including alpha/beta/rc tags # release = '0.5.2-rc2' -release = '0.13.1' +release = '0.13.2' # -- General configuration --------------------------------------------------- diff --git a/src/doc/index.rst b/src/doc/index.rst index 5e5a5e8801b0bd4c91ce225766d7973ee8fa2b92..24373d4d7c7d68be51915b25cc6201a84a6a4dc0 100644 --- a/src/doc/index.rst +++ b/src/doc/index.rst @@ -14,13 +14,19 @@ Welcome to PyLinkAhead's documentation! Administration <administration> High Level API <high_level_api> Code gallery <gallery/index> - API documentation<_apidoc/linkahead> + API documentation <_apidoc/linkahead> + Back to Overview <https://docs.indiscale.com/> + This is the documentation for the Python client library for LinkAhead, ``PyLinkAhead``. This documentation helps you to :doc:`get started<README_SETUP>`, explains the most important :doc:`concepts<concepts>` and offers a range of :doc:`tutorials<tutorials/index>`. +Or go back to the general `overview`_ of the documentation. + +.. _overview: https://docs.indiscale.com/ + Indices and tables ================== diff --git a/src/linkahead/common/datatype.py b/src/linkahead/common/datatype.py index 832844567bca31f4c46e205094daa709a8af9e71..c0c15feca240112f1f8e33a0cd37932151fcd9f0 100644 --- a/src/linkahead/common/datatype.py +++ b/src/linkahead/common/datatype.py @@ -37,22 +37,35 @@ BOOLEAN = "BOOLEAN" def LIST(datatype): + # FIXME May be ambiguous (if name duplicate) or insufficient (if only ID exists). if hasattr(datatype, "name"): datatype = datatype.name return "LIST<" + str(datatype) + ">" -def get_list_datatype(datatype): - """ returns the datatype of the elements in the list """ - if not isinstance(datatype, str): - return None - match = re.match("LIST(<|<)(?P<datatype>.*)(>|>)", datatype) +def get_list_datatype(datatype: str, strict: bool = False): + """Returns the datatype of the elements in the list. If it not a list, return None.""" + # TODO Union[str, Entity] + if not isinstance(datatype, str) or not datatype.lower().startswith("list"): + if strict: + raise ValueError(f"Not a list dtype: {datatype}") + else: + return None + pattern = r"^[Ll][Ii][Ss][Tt]((<|<)(?P<dtype1>.*)(>|>)|\((?P<dtype2>.*)\))$" + match = re.match(pattern, datatype) + + if match and "dtype1" in match.groupdict() and match.groupdict()["dtype1"] is not None: + return match.groupdict()["dtype1"] + + elif match and "dtype2" in match.groupdict() and match.groupdict()["dtype2"] is not None: + return match.groupdict()["dtype2"] - if match is not None: - return match.group("datatype") else: - return None + if strict: + raise ValueError(f"Not a list dtype: {datatype}") + else: + return None def is_list_datatype(datatype): diff --git a/src/linkahead/common/models.py b/src/linkahead/common/models.py index 75ee469bfb78f43054cffd2d29d723804ababc5f..38c1349067fce68dc3dc0311dc621bd0e383d4b0 100644 --- a/src/linkahead/common/models.py +++ b/src/linkahead/common/models.py @@ -63,6 +63,7 @@ from ..exceptions import (AmbiguousEntityError, AuthorizationError, UniqueNamesError, UnqualifiedParentsError, UnqualifiedPropertiesError) from .datatype import (BOOLEAN, DATETIME, DOUBLE, INTEGER, TEXT, + get_list_datatype, is_list_datatype, is_reference) from .state import State from .timezone import TimeZone @@ -3250,14 +3251,19 @@ class Container(list): return sync_dict - def _test_dependencies_in_container(self, container): - """This function returns those elements of a given container that are a dependency of another element of the same container. + @staticmethod + def _find_dependencies_in_container(container): + """Find elements in a container that are a dependency of another element of the same. - Args: - container (Container): a linkahead container + Parameters + ---------- + container : Container + A LinkAhead container. - Returns: - [set]: a set of unique elements that are a dependency of another element of `container` + Returns + ------- + out : set + A set of IDs of unique elements that are a dependency of another element of ``container``. """ item_id = set() is_parent = set() @@ -3274,28 +3280,54 @@ class Container(list): for parents in container_item.get_parents(): is_parent.add(parents.id) - for references in container_item.get_properties(): - if is_reference(references.datatype): - # add only if it is a reference, not a property - - if references.value is None: - continue - elif isinstance(references.value, int): - is_being_referenced.add(references.value) - elif is_list_datatype(references.datatype): - for list_item in references.value: - if isinstance(list_item, int): - is_being_referenced.add(list_item) - else: - is_being_referenced.add(list_item.id) - else: - try: - is_being_referenced.add(references.value.id) - except AttributeError: + for prop in container_item.get_properties(): + prop_dt = prop.datatype + if is_reference(prop_dt): + # add only if it is a reference, not a simple property + # Step 1: look for prop.value + if prop.value is not None: + if isinstance(prop.value, int): + is_being_referenced.add(prop.value) + elif is_list_datatype(prop_dt): + for list_item in prop.value: + if isinstance(list_item, int): + is_being_referenced.add(list_item) + else: + is_being_referenced.add(list_item.id) + else: + try: + is_being_referenced.add(prop.value.id) + except AttributeError: + pass + # Step 2: Reference properties + if prop.is_reference(): + if is_list_datatype(prop_dt): + ref_name = get_list_datatype(prop_dt) + try: + is_being_referenced.add(container.get_entity_by_name(ref_name).id) + except KeyError: + pass + elif isinstance(prop_dt, str): pass - - if hasattr(references, 'id'): - is_property.add(references.id) + else: + is_being_referenced.add(prop_dt.id) + + if hasattr(prop, 'id'): + is_property.add(prop.id) + if isinstance(container_item, Property): + dtype = container_item.datatype + if isinstance(dtype, Entity): + is_being_referenced.add(dtype.id) + elif isinstance(dtype, str): + if is_list_datatype(dtype): + dtype = get_list_datatype(dtype) + try: + is_being_referenced.add(container.get_entity_by_name(dtype).id) + except KeyError: + pass + else: + # plain old scalar datatype + pass dependent_parents = item_id.intersection(is_parent) dependent_properties = item_id.intersection(is_property) @@ -3312,19 +3344,18 @@ class Container(list): name otherwise. If any entity has no id and no name a TransactionError will be raised. - Note: If only a name is given this could lead to ambiguities. If - this happens, none of them will be deleted. It occurs an error - instead. + Note: If only a name is given this could lead to ambiguities. If this happens, none of them + will be deleted. An error is raised instead. + """ item_count = len(self) # Split Container in 'chunk_size'-sized containers (if necessary) to avoid error 414 Request-URI Too Long if item_count > chunk_size: - dependencies = self._test_dependencies_in_container(self) - ''' - If there are as many dependencies as entities in the container and it is larger than chunk_size it cannot be split and deleted. - This case cannot be handled at the moment. - ''' + dependencies = Container._find_dependencies_in_container(self) + + # If there are as many dependencies as entities in the container and it is larger than + # chunk_size it cannot be split and deleted. This case cannot be handled at the moment. if len(dependencies) == item_count: if raise_exception_on_error: diff --git a/src/linkahead/connection/connection.py b/src/linkahead/connection/connection.py index db6b66f17dd3eb8c4415119912d5586c6543b953..91b4a01da455d0f365e39b0b0f7359e07096e707 100644 --- a/src/linkahead/connection/connection.py +++ b/src/linkahead/connection/connection.py @@ -509,7 +509,8 @@ def _handle_response_status(http_response): raise LoginFailedError(standard_message) elif status == 403: raise HTTPForbiddenError(standard_message) - elif status in (413, 414): + elif status in (413, 414, 431): + # Content (413), URI (414) or complete HTTP headers (URI+headers) (431) too long raise HTTPURITooLongError(standard_message) elif 399 < status < 500: raise HTTPClientError(msg=standard_message, status=status, body=body) diff --git a/unittests/docker/Dockerfile b/unittests/docker/Dockerfile index a5f355fe23d00449ea470fa80f81a4e8e1914242..9b848cf69c829408f3f3edd599323b6b0321e041 100644 --- a/unittests/docker/Dockerfile +++ b/unittests/docker/Dockerfile @@ -10,6 +10,6 @@ RUN apt-get update && \ python3-sphinx ARG COMMIT="dev" # TODO Rename to linkahead -RUN git clone -b dev https://gitlab.indiscale.com/caosdb/src/linkahead-pylib.git linkahead-pylib && \ +RUN git clone -b dev https://gitlab.indiscale.com/caosdb/src/caosdb-pylib.git linkahead-pylib && \ cd linkahead-pylib && git checkout $COMMIT && pip3 install . RUN pip3 install recommonmark sphinx-rtd-theme diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index bb6f978bb83c4ee32cc485f538b8807c8f7012dd..b9a02926803c1e7b8134cde904ea2021d0281ff4 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -362,6 +362,20 @@ def test_bug_109(): r_a) +@pytest.mark.xfail(reason="Issue https://gitlab.com/linkahead/linkahead-pylib/-/issues/111") +def test_failing_merge_entities_111(): + prop_a = db.Property() + prop_parent = db.Property(name="prop_parent") + prop_b = db.Property(name="b", datatype=db.DOUBLE, unit="µs", value=1.1).add_parent(prop_parent) + print(prop_b) + db.apiutils.merge_entities(prop_a, prop_b) + assert prop_a.name == prop_b.name # OK + assert prop_parent.name in [par.name for par in prop_a.get_parents()] # OK + assert prop_a.value == prop_b.value # fails + assert prop_a.datatype == db.DOUBLE # fails + assert prop_a.unit == prop_b.unit # fails + + def test_wrong_merge_conflict_reference(): """Test a wrongly detected merge conflict in case of two records referencing two different, but identical objects. diff --git a/unittests/test_container.py b/unittests/test_container.py index 113dd6223a9a8cd246b3b2998faa586fbae3da11..4cd8fefcaefee9fe6fdc5857805353227b493dfb 100644 --- a/unittests/test_container.py +++ b/unittests/test_container.py @@ -23,7 +23,7 @@ # ** end header # """Tests for the Container class.""" -from __future__ import absolute_import +import pytest import linkahead as db @@ -125,7 +125,7 @@ def test_container_dependencies_for_deletion(): record_with_parent, record_with_property_which_is_not_a_record ]) - assert (db.Container()._test_dependencies_in_container(container) + assert (db.Container._find_dependencies_in_container(container) == {2002, 1005, 1007}) @@ -143,4 +143,38 @@ def test_container_dependencies_for_deletion_with_lists(): container = db.Container() container.extend([record_with_list, record_referenced]) - assert db.Container()._test_dependencies_in_container(container) == {2001} + assert db.Container._find_dependencies_in_container(container) == {2001} + + +def test_container_deletion_with_references(): + """Test if dependencies are checked correctly. + """ + + RT1 = db.RecordType(name="RT1") + RT2 = db.RecordType(name="RT2").add_property(name="prop2", datatype=RT1) + RT3 = db.RecordType(name="RT3").add_property(name="prop3", datatype="LIST<RT1>") + prop4a = db.Property(name="prop4a", datatype=RT1) + prop4b = db.Property(name="prop4b", datatype="RT1") + prop5 = db.Property(name="prop5", datatype="LIST<RT1>") + cont12 = db.Container().extend([RT1, RT2]) + cont13 = db.Container().extend([RT1, RT3]) + cont14a = db.Container().extend([RT1, prop4a]) + cont14b = db.Container().extend([RT1, prop4b]) + cont15 = db.Container().extend([RT1, prop5]) + cont12.to_xml() + cont13.to_xml() + cont14a.to_xml() + cont14b.to_xml() + cont15.to_xml() + + deps12 = db.Container._find_dependencies_in_container(cont12) + deps13 = db.Container._find_dependencies_in_container(cont13) + deps14a = db.Container._find_dependencies_in_container(cont14a) + deps14b = db.Container._find_dependencies_in_container(cont14b) + deps15 = db.Container._find_dependencies_in_container(cont15) + + assert len(deps12) == 1 and deps12.pop() == -1 + assert len(deps13) == 1 and deps13.pop() == -1 + assert len(deps14a) == 1 and deps14a.pop() == -1 + assert len(deps14b) == 1 and deps14b.pop() == -1 + assert len(deps15) == 1 and deps15.pop() == -1 diff --git a/unittests/test_datatype.py b/unittests/test_datatype.py index 5a5e82cc5bfba9ac46a91b4baf4fe45665049c84..838edc120755e564cd6d237193a354c20652d492 100644 --- a/unittests/test_datatype.py +++ b/unittests/test_datatype.py @@ -33,6 +33,17 @@ def test_list_utilites(): """Test for example if get_list_datatype works.""" dtype = db.LIST(db.INTEGER) assert datatype.get_list_datatype(dtype) == db.INTEGER + assert datatype.get_list_datatype("LIST(Person)") == "Person" + assert datatype.get_list_datatype("Person") is None + assert datatype.get_list_datatype("LIST[]") is None + with raises(ValueError): + datatype.get_list_datatype("LIST[]", strict=True) + with raises(ValueError): + datatype.get_list_datatype("Person", strict=True) + with raises(ValueError): + datatype.get_list_datatype(5, strict=True) + with raises(ValueError): + datatype.get_list_datatype("listlol", strict=True) def test_parsing_of_intger_list_values():