diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0f9a258de99ba559d280fc5ace74a3f111a9e30e..8845e4070c685230a99958fbebd9377238df32de 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -113,15 +113,8 @@ unittest_py3.13: tags: [ docker ] stage: test needs: [ ] - image: python:3.13-rc - script: - # TODO: Replace by '*python_test_script' as soon as 3.13 has been officially released. - # Python docker has problems with tox and pip so use plain pytest here - - apt update && apt install -y cargo - - touch ~/.pylinkahead.ini - - pip install pynose pytest pytest-cov jsonschema>=4.4.0 setuptools - - pip install . - - python -m pytest unittests + image: python:3.13 + script: *python_test_script # Trigger building of server image and integration tests trigger_build: diff --git a/CHANGELOG.md b/CHANGELOG.md index de363eb791697fcc53171b6e4d0694da1036e34a..de8099318cdc1480f2cd2c06497a7cd374e65495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ 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.16.0] - 2024-11-13 ## + +### Added ### + +* `ParentList` and `PropertyList` now have a `filter` function that allows to select a subset of + the contained elements by ID and/or name. +* Official support for Python 3.13 +* Added arguments to `describe_diff` that allow customizing the labels for the 'old' and the 'new' diffs. +* Optional `realm` argument for `linkahead_admin.py set_user_password` + which defaults to `None`, i.e., the server's default realm. + +### Changed ### + +* `compare_entities` is now case insensitive with respect to property and + recordtype names +* `_ParentList` is now called `ParentList` +* `_Properties` is now called `PropertyList` +* `ParentList.remove` is now case insensitive when a name is used. + +### Deprecated ### + +* the use of the arguments `old_entity` and `new_entity` in `compare_entities` + is now deprecated. Please use `entity0` and `entity1` respectively instead. + +### Fixed ### + +* [gitlab.indiscale.com#200](https://gitlab.indiscale.com/caosdb/src/caosdb-pylib/-/merge_requests/153) + ``linkahead_admin.py`` prints reasonable error messages when users + or roles don't exist. + ## [0.15.1] - 2024-08-21 ## ### Deprecated ### diff --git a/CITATION.cff b/CITATION.cff index 3f51bdf839a5e0451f3d3aaf7f128f61b29927fc..123289ca17e8b43446f8f368621debccd8c27469 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.15.1 +version: 0.16.0 doi: 10.3390/data4020083 -date-released: 2024-08-21 +date-released: 2024-11-13 diff --git a/examples/set_permissions.py b/examples/set_permissions.py index a558bde73897cb6827c93373cc8327efc10e6e15..4657f2cca182b567c761a777df838825f8e89aef 100755 --- a/examples/set_permissions.py +++ b/examples/set_permissions.py @@ -37,13 +37,13 @@ from caosdb import administration as admin def assert_user_and_role(): """Make sure that users and roles exist. -After calling this function, there will be a user "jane" with the role "human" -and the user "xaxys" with the role "alien". These users and roles are returned. + After calling this function, there will be a user "jane" with the role "human" + and the user "xaxys" with the role "alien". These users and roles are returned. -Returns -------- -out : tuple - ((human_user, human_role), (alien_user, alien_role)) + Returns + ------- + out : tuple + ((human_user, human_role), (alien_user, alien_role)) """ try: @@ -81,15 +81,15 @@ out : tuple def get_entities(count=1): """Retrieve one or more entities. -Parameters ----------- -count : int, optional - How many entities to retrieve. + Parameters + ---------- + count : int, optional + How many entities to retrieve. -Returns -------- -out : Container - A container of retrieved entities, the length is given by the parameter count. + Returns + ------- + out : Container + A container of retrieved entities, the length is given by the parameter count. """ cont = db.execute_query("FIND RECORD 'Human Food'", flags={ "P": "0L{n}".format(n=count)}) @@ -102,20 +102,20 @@ out : Container def set_permission(role_grant, role_deny, cont=None, general=False): """Set the permissions of some entities. -Parameters ----------- -role_grant : str - Role which is granted permissions. + Parameters + ---------- + role_grant : str + Role which is granted permissions. -role_deny : str - Role which is denied permissions. + role_deny : str + Role which is denied permissions. -cont : Container - Entities for which permissions are set. + cont : Container + Entities for which permissions are set. -general : bool, optional - If True, the permissions for the roles will be set. If False (the default), - permissions for the entities in the container will be set. + general : bool, optional + If True, the permissions for the roles will be set. If False (the default), + permissions for the entities in the container will be set. """ # Set general permissions @@ -143,23 +143,23 @@ general : bool, optional def test_permission(granted_user, denied_user, cont): """Tests if the permissions are set correctly for two users. -Parameters ----------- -granted_user : (str, str) - The user which should have permissions to retrieve the entities in `cont`. - Given as (user, password). + Parameters + ---------- + granted_user : (str, str) + The user which should have permissions to retrieve the entities in `cont`. + Given as (user, password). -denied_user : (str, str) - The user which should have no permission to retrieve the entities in `cont`. - Given as (user, password). + denied_user : (str, str) + The user which should have no permission to retrieve the entities in `cont`. + Given as (user, password). -cont : Container - Entities for which permissions are tested. + cont : Container + Entities for which permissions are tested. -Returns -------- -None + Returns + ------- + None """ diff --git a/setup.py b/setup.py index 6ad2d0b9ef1e4c07d6519562a0c75c72c51b5b75..b8a04adefa9d8891c10576a733f53f2fa88b981d 100755 --- a/setup.py +++ b/setup.py @@ -48,8 +48,8 @@ from setuptools import find_packages, setup ISRELEASED = True MAJOR = 0 -MINOR = 15 -MICRO = 1 +MINOR = 16 +MICRO = 0 # 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 7b127420c281e37e82ee0e64768ae831e30e2798..f25ed399575bb208e699476b06f014abe43ee967 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -25,14 +25,14 @@ import sphinx_rtd_theme # noqa: E402 # -- Project information ----------------------------------------------------- project = 'pylinkahead' -copyright = '2023, IndiScale GmbH' +copyright = '2024, IndiScale GmbH' author = 'Daniel Hornung' # The short X.Y version -version = '0.15.1' +version = '0.16.0' # The full version, including alpha/beta/rc tags # release = '0.5.2-rc2' -release = '0.15.1' +release = '0.16.0' # -- General configuration --------------------------------------------------- diff --git a/src/doc/tutorials/complex_data_models.rst b/src/doc/tutorials/complex_data_models.rst index 569acdae174a9df9d0d2b5eae9a0084d793cc90c..168cf3b9f0d6839ed8f78beb01ae24fb9d489e88 100644 --- a/src/doc/tutorials/complex_data_models.rst +++ b/src/doc/tutorials/complex_data_models.rst @@ -51,18 +51,18 @@ Examples # Very complex part of the data model: # Case 1: File added to another file f2.add_property(p1, value=f1) # this adds a file property with value first file - # to the second file + # to the second file # Case 2: Property added to a property p2.add_property(p3, value=27) # this adds an integer property with value 27 to the - # double property + # double property # Case 3: Reference property added to a property # The property p2 now has two sub properties, one is pointing to # record p2 which itself has the property p2, therefore this can be # considered a loop in the data model. p2.add_property(p4, value=r2) # this adds a reference property pointing to - # record 2 to the double property + # record 2 to the double property # Insert a container containing all the newly created entities: c = db.Container().extend([rt1, rt2, r1, r2, f1, p1, p2, p3, f2, p4]) @@ -75,3 +75,54 @@ Examples b = input("Press any key to cleanup.") # cleanup everything after the user presses any button. c.delete() + + +Finding parents and properties +-------- +To find a specific parent or property of an Entity, its +ParentList or PropertyList can be filtered using names, ids, or +entities. A short example: + +.. code-block:: python3 + + import linkahead as db + + # Setup a record with six properties + r = db.Record() + p1_1 = db.Property(id=101, name="Property 1") + p1_2 = db.Property(name="Property 1") + p2_1 = db.Property(id=102, name="Property 2") + p2_2 = db.Property(id=102) + p2_3 = db.Property(id=102, name="Other Property") + p3 = db.Property(id=104, name="Other Property") + r.add_property(p1_1).add_property(p1_2).add_property(p2_1) + r.add_property(p2_2).add_property(p2_3).add_property(p3) + properties = r.properties + + # As r only has one property with id 101, this returns a list containing only p1_1 + properties.filter(pid=101) + # Result: [p1_1] + + # Filtering with name="Property 1" returns both p1_1 and p1_2, as they share their name + properties.filter(name="Property 1") + # Result: [p1_1, p1_2] + + # If both name and pid are given, matching is based only on pid for all entities that have an id + properties.filter(pid="102", name="Other Property") + # Result: [p2_1, p2_2, p2_3] + + # However, filtering with name="Property 1" and id=101 returns both p1_1 and p1_2, because + # p1_2 does not have an id and matches the name + properties.filter(pid="101", name="Property 1") + # Result: [p1_1, p1_2] + + # We can also filter using an entity, in which case the name and id of the entity are used: + properties.filter(pid="102", name="Property 2") == properties.filter(p2_1) + # Result: True + + # If we only need properties that match both id and name, we can set the parameter + # conjunction to True: + properties.filter(pid="102", name="Property 2", conjunction=True) + # Result: [p2_1] + +The filter function of ParentList works analogously. diff --git a/src/linkahead/__init__.py b/src/linkahead/__init__.py index cd54f8f4e05326579521fbbf226f027d32fa616e..567748e3b3a58fb73b91f652d82ed10f818d6014 100644 --- a/src/linkahead/__init__.py +++ b/src/linkahead/__init__.py @@ -42,7 +42,7 @@ from .common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, LIST, REFERENCE, TEXT) # Import of the basic API classes: from .common.models import (ACL, ALL, FIX, NONE, OBLIGATORY, RECOMMENDED, - SUGGESTED, Container, DropOffBox, Entity, File, + SUGGESTED, Container, DropOffBox, Entity, File, Parent, Info, Message, Permissions, Property, Query, QueryTemplate, Record, RecordType, delete, execute_query, get_global_acl, diff --git a/src/linkahead/apiutils.py b/src/linkahead/apiutils.py index 4ae8edd16f1fdc00eb7ba2c17661eea6e114885e..49336aa8db24fba663337185c5c37a346330c4cd 100644 --- a/src/linkahead/apiutils.py +++ b/src/linkahead/apiutils.py @@ -28,10 +28,11 @@ """ from __future__ import annotations + import logging import warnings from collections.abc import Iterable -from typing import Any, Union, Optional +from typing import Any, Optional, Union from .common.datatype import is_reference from .common.models import (SPECIAL_ATTRIBUTES, Container, Entity, File, @@ -179,183 +180,313 @@ def getCommitIn(folder): return get_commit_in(folder) -def compare_entities(old_entity: Entity, - new_entity: Entity, - compare_referenced_records: bool = False +def compare_entities(entity0: Optional[Entity] = None, + entity1: Optional[Entity] = None, + compare_referenced_records: bool = False, + entity_name_id_equivalency: bool = False, + old_entity: Optional[Entity] = None, + new_entity: Optional[Entity] = None, ) -> tuple[dict[str, Any], dict[str, Any]]: - """Compare two entites. - - Return a tuple of dictionaries, the first index belongs to additional information for old - entity, the second index belongs to additional information for new entity. - - Additional information means in detail: - - Additional parents (a list under key "parents") - - Information about properties: - - Each property lists either an additional property or a property with a changed: + """Compare two entities. + + Returns two dicts listing the differences between the two entities. The + order of the two returned dicts corresponds to the two input entities. + The dicts contain two keys, 'parents' and 'properties'. The list saved + under the 'parents' key contains those parents of the respective entity + that are missing in the other entity, and the 'properties' dict contains + properties and SPECIAL_ATTRIBUTES if they are missing or different from + their counterparts in the other entity. + + The value of the properties dict for each listed property is again a dict + detailing the differences between this property and its counterpart. + The characteristics that are checked to determine whether two properties + match are the following: - datatype - - importance or - - value (not implemented yet) - - In case of changed information the value listed under the respective key shows the - value that is stored in the respective entity. - - If `compare_referenced_records` is `True`, also referenced entities will be - compared using this function (which is then called with - `compare_referenced_records = False` to prevent infinite recursion in case - of circular references). - - Parameters - ---------- - old_entity, new_entity : Entity - Entities to be compared - compare_referenced_records : bool, optional - Whether to compare referenced records in case of both, `old_entity` and - `new_entity`, have the same reference properties and both have a Record - object as value. If set to `False`, only the corresponding Python - objects are compared which may lead to unexpected behavior when - identical records are stored in different objects. Default is False. - + - importance + - value + If any of these characteristics differ for a property, the respective + string (datatype, importance, value) is added as a key to the dict of the + property with its value being the characteristics value, + e.g. {"prop": {"value": 6, 'importance': 'SUGGESTED'}}. Except: None as + value is not added to the dict. + If a property is of type LIST, the comparison is order-sensitive. + + Comparison of multi-properties is not yet supported, so should either + entity have several instances of one Property, the comparison is aborted + and an error is raised. + + Two parents match if their name and id are the same, any further + differences are ignored. + + Should records referenced in the value field not be checked for equality + between the entities but for equivalency, this is possible by setting the + parameter compare_referenced_records. + + Params + ------ + entity0 : Entity + First entity to be compared. + entity1 : Entity + Second entity to be compared. + compare_referenced_records: bool, default: False + If set to True, values with referenced records + are not checked for equality but for + equivalency using this function. + compare_referenced_records is set to False for + these recursive calls, so references of + references need to be equal. If set to `False`, + only the Python objects are compared, which may + lead to unexpected behavior. + entity_name_id_equivalency: bool, default: False + If set to True, the comparison between an + entity and an int or str also checks whether + the int/str matches the name or id of the + entity, so Entity(id=100) == 100 == "100". """ - olddiff: dict[str, Any] = {"properties": {}, "parents": []} - newdiff: dict[str, Any] = {"properties": {}, "parents": []} - - if old_entity is new_entity: - return (olddiff, newdiff) - - if type(old_entity) is not type(new_entity): + # ToDo: Discuss intended behaviour + # Questions that need clarification: + # - What is intended behaviour for multi-properties and multi-parents? + # - Do different inheritance levels for parents count as a difference? + # - Do we care about parents and properties of properties? + # - Should there be a more detailed comparison of parents without id? + # - Revisit filter - do we care about RecordType when matching? + # How to treat None? + # - Should matching of parents also take the recordtype into account + # for parents that have a name but no id? + # Suggestions for enhancements: + # - For the comparison of entities in value and properties, consider + # keeping a list of traversed entities, not only look at first layer + # - Make the empty_diff functionality faster by adding a parameter to + # this function so that it returns after the first found difference? + # - Add parameter to restrict diff to some characteristics + # - Implement comparison of date where one is a string and the other is + # datetime + if entity0 is None and old_entity is None: + raise ValueError("Please provide the first entity as first argument (`entity0`)") + if entity1 is None and new_entity is None: + raise ValueError("Please provide the second entity as second argument (`entity1`)") + if old_entity is not None: + warnings.warn("Please use 'entity0' instead of 'old_entity'.", DeprecationWarning) + if entity0 is not None: + raise ValueError("You cannot use both entity0 and old_entity") + entity0 = old_entity + if new_entity is not None: + warnings.warn("Please use 'entity1' instead of 'new_entity'.", DeprecationWarning) + if entity1 is not None: + raise ValueError("You cannot use both entity1 and new_entity") + entity1 = new_entity + + diff: tuple = ({"properties": {}, "parents": []}, + {"properties": {}, "parents": []}) + + if entity0 is entity1: + return diff + + if type(entity0) is not type(entity1): raise ValueError( "Comparison of different Entity types is not supported.") + # compare special attributes for attr in SPECIAL_ATTRIBUTES: - try: - oldattr = old_entity.__getattribute__(attr) - old_entity_attr_exists = True - except BaseException: - old_entity_attr_exists = False - try: - newattr = new_entity.__getattribute__(attr) - new_entity_attr_exists = True - except BaseException: - new_entity_attr_exists = False - - if old_entity_attr_exists and (oldattr == "" or oldattr is None): - old_entity_attr_exists = False - - if new_entity_attr_exists and (newattr == "" or newattr is None): - new_entity_attr_exists = False - - if not old_entity_attr_exists and not new_entity_attr_exists: + if attr == "value": continue - if ((old_entity_attr_exists ^ new_entity_attr_exists) - or (oldattr != newattr)): - - if old_entity_attr_exists: - olddiff[attr] = oldattr + attr0 = entity0.__getattribute__(attr) + # we consider "" and None to be nonexistent + attr0_unset = (attr0 == "" or attr0 is None) - if new_entity_attr_exists: - newdiff[attr] = newattr + attr1 = entity1.__getattribute__(attr) + # we consider "" and None to be nonexistent + attr1_unset = (attr1 == "" or attr1 is None) - # properties - - for prop in old_entity.properties: - matching = [p for p in new_entity.properties if p.name == prop.name] + # in both entities the current attribute is not set + if attr0_unset and attr1_unset: + continue + # treat datatype separately if one datatype is an object and the other + # a string or int, and therefore may be a name or id + if attr == "datatype": + if not attr0_unset and not attr1_unset: + if isinstance(attr0, RecordType): + if attr0.name == attr1: + continue + if str(attr0.id) == str(attr1): + continue + if isinstance(attr1, RecordType): + if attr1.name == attr0: + continue + if str(attr1.id) == str(attr0): + continue + + # add to diff if attr has different values or is not set for one entity + if (attr0_unset != attr1_unset) or (attr0 != attr1): + diff[0][attr] = attr0 + diff[1][attr] = attr1 + + # compare value + ent0_val, ent1_val = entity0.value, entity1.value + if ent0_val != ent1_val: + same_value = False + + # Surround scalar values with a list to avoid code duplication - + # this way, the scalar values can be checked against special cases + # (compare refs, entity id equivalency etc.) in the list loop + if not isinstance(ent0_val, list) and not isinstance(ent1_val, list): + ent0_val, ent1_val = [ent0_val], [ent1_val] + + if isinstance(ent0_val, list) and isinstance(ent1_val, list): + # lists can't be the same if the lengths are different + if len(ent0_val) == len(ent1_val): + lists_match = True + for val0, val1 in zip(ent0_val, ent1_val): + if val0 == val1: + continue + # Compare Entities + if (compare_referenced_records and + isinstance(val0, Entity) and isinstance(val1, Entity)): + try: + same = empty_diff(val0, val1, False, + entity_name_id_equivalency) + except (ValueError, NotImplementedError): + same = False + if same: + continue + # Compare Entity name and id + if entity_name_id_equivalency: + if (isinstance(val0, Entity) + and isinstance(val1, (int, str))): + if (str(val0.id) == str(val1) + or str(val0.name) == str(val1)): + continue + if (isinstance(val1, Entity) + and isinstance(val0, (int, str))): + if (str(val1.id) == str(val0) + or str(val1.name) == str(val0)): + continue + # val0 and val1 could not be matched + lists_match = False + break + if lists_match: + same_value = True + + if not same_value: + diff[0]["value"] = entity0.value + diff[1]["value"] = entity1.value + + # compare properties + for prop in entity0.properties: + matching = entity1.properties.filter(name=prop.name, pid=prop.id) if len(matching) == 0: - olddiff["properties"][prop.name] = {} + # entity1 has prop, entity0 does not + diff[0]["properties"][prop.name] = {} elif len(matching) == 1: - newdiff["properties"][prop.name] = {} - olddiff["properties"][prop.name] = {} - - if (old_entity.get_importance(prop.name) != - new_entity.get_importance(prop.name)): - olddiff["properties"][prop.name]["importance"] = \ - old_entity.get_importance(prop.name) - newdiff["properties"][prop.name]["importance"] = \ - new_entity.get_importance(prop.name) - - if (prop.datatype != matching[0].datatype): - olddiff["properties"][prop.name]["datatype"] = prop.datatype - newdiff["properties"][prop.name]["datatype"] = \ - matching[0].datatype - - if (prop.unit != matching[0].unit): - olddiff["properties"][prop.name]["unit"] = prop.unit - newdiff["properties"][prop.name]["unit"] = \ - matching[0].unit - - if (prop.value != matching[0].value): - # basic comparison of value objects says they are different - same_value = False - if compare_referenced_records: - # scalar reference - if isinstance(prop.value, Entity) and isinstance(matching[0].value, Entity): - # explicitely not recursive to prevent infinite recursion - same_value = empty_diff( - prop.value, matching[0].value, compare_referenced_records=False) - # list of references - elif isinstance(prop.value, list) and isinstance(matching[0].value, list): - # all elements in both lists actually are entity objects - # TODO: check, whether mixed cases can be allowed or should lead to an error - if (all([isinstance(x, Entity) for x in prop.value]) - and all([isinstance(x, Entity) for x in matching[0].value])): - # can't be the same if the lengths are different - if len(prop.value) == len(matching[0].value): - # do a one-by-one comparison: - # the values are the same if all diffs are empty - same_value = all( - [empty_diff(x, y, False) for x, y - in zip(prop.value, matching[0].value)]) - - if not same_value: - olddiff["properties"][prop.name]["value"] = prop.value - newdiff["properties"][prop.name]["value"] = \ - matching[0].value - - if (len(newdiff["properties"][prop.name]) == 0 - and len(olddiff["properties"][prop.name]) == 0): - newdiff["properties"].pop(prop.name) - olddiff["properties"].pop(prop.name) + diff[0]["properties"][prop.name] = {} + diff[1]["properties"][prop.name] = {} + propdiff = (diff[0]["properties"][prop.name], + diff[1]["properties"][prop.name]) + + # We should compare the wrapped properties instead of the + # wrapping entities if possible: + comp1, comp2 = prop, matching[0] + if (comp1._wrapped_entity is not None + and comp2._wrapped_entity is not None): + comp1, comp2 = comp1._wrapped_entity, comp2._wrapped_entity + # Recursive call to determine the differences between properties + # Note: Can lead to infinite recursion if two properties have + # themselves or each other as subproperties + od, nd = compare_entities(comp1, comp2, compare_referenced_records, + entity_name_id_equivalency) + # We do not care about parents and properties here, discard + od.pop("parents") + od.pop("properties") + nd.pop("parents") + nd.pop("properties") + # use the remaining diff + propdiff[0].update(od) + propdiff[1].update(nd) + + # As the importance of a property is an attribute of the record + # and not the property, it is not contained in the diff returned + # by compare_entities and needs to be added separately + if (entity0.get_importance(prop) != + entity1.get_importance(matching[0])): + propdiff[0]["importance"] = entity0.get_importance(prop) + propdiff[1]["importance"] = entity1.get_importance(matching[0]) + + # in case there is no difference, we remove the dict keys again + if len(propdiff[0]) == 0 and len(propdiff[1]) == 0: + diff[0]["properties"].pop(prop.name) + diff[1]["properties"].pop(prop.name) else: raise NotImplementedError( "Comparison not implemented for multi-properties.") - for prop in new_entity.properties: - if len([0 for p in old_entity.properties if p.name == prop.name]) == 0: - newdiff["properties"][prop.name] = {} - - # parents - - for parent in old_entity.parents: - if len([0 for p in new_entity.parents if p.name == parent.name]) == 0: - olddiff["parents"].append(parent.name) - - for parent in new_entity.parents: - if len([0 for p in old_entity.parents if p.name == parent.name]) == 0: - newdiff["parents"].append(parent.name) - - return (olddiff, newdiff) - + # we have not yet compared properties that do not exist in entity0 + for prop in entity1.properties: + # check how often the property appears in entity0 + num_prop_in_ent0 = len(entity0.properties.filter(prop)) + if num_prop_in_ent0 == 0: + # property is only present in entity0 - add to diff + diff[1]["properties"][prop.name] = {} + if num_prop_in_ent0 > 1: + # Check whether the property is present multiple times in entity0 + # and raise error - result would be incorrect + raise NotImplementedError( + "Comparison not implemented for multi-properties.") -def empty_diff(old_entity: Entity, new_entity: Entity, - compare_referenced_records: bool = False) -> bool: + # compare parents + for index, parents, other_entity in [(0, entity0.parents, entity1), + (1, entity1.parents, entity0)]: + for parent in parents: + matching = other_entity.parents.filter(parent) + if len(matching) == 0: + diff[index]["parents"].append(parent.name) + continue + + return diff + + +def empty_diff(entity0: Entity, + entity1: Entity, + compare_referenced_records: bool = False, + entity_name_id_equivalency: bool = False, + old_entity: Optional[Entity] = None, + new_entity: Optional[Entity] = None, + ) -> bool: """Check whether the `compare_entities` found any differences between - old_entity and new_entity. + entity0 and entity1. Parameters ---------- - old_entity, new_entity : Entity + entity0, entity1 : Entity Entities to be compared compare_referenced_records : bool, optional - Whether to compare referenced records in case of both, `old_entity` and - `new_entity`, have the same reference properties and both have a Record + Whether to compare referenced records in case of both, `entity0` and + `entity1`, have the same reference properties and both have a Record object as value. - + entity_name_id_equivalency : bool, optional + If set to True, the comparison between an entity and an int or str also + checks whether the int/str matches the name or id of the entity, so + Entity(id=100) == 100 == "100". """ - olddiff, newdiff = compare_entities( - old_entity, new_entity, compare_referenced_records) - for diff in [olddiff, newdiff]: + if entity0 is None and old_entity is None: + raise ValueError("Please provide the first entity as first argument (`entity0`)") + if entity1 is None and new_entity is None: + raise ValueError("Please provide the second entity as second argument (`entity1`)") + if old_entity is not None: + warnings.warn("Please use 'entity0' instead of 'old_entity'.", DeprecationWarning) + if entity0 is not None: + raise ValueError("You cannot use both entity0 and old_entity") + entity0 = old_entity + if new_entity is not None: + warnings.warn("Please use 'entity1' instead of 'new_entity'.", DeprecationWarning) + if entity1 is not None: + raise ValueError("You cannot use both entity1 and new_entity") + entity1 = new_entity + e0diff, e1diff = compare_entities(entity0, entity1, compare_referenced_records, + entity_name_id_equivalency) + for diff in [e0diff, e1diff]: for key in ["parents", "properties"]: if len(diff[key]) > 0: # There is a difference somewhere in the diff @@ -376,9 +507,9 @@ def merge_entities(entity_a: Entity, ) -> Entity: """Merge entity_b into entity_a such that they have the same parents and properties. - datatype, unit, value, name and description will only be changed in entity_a - if they are None for entity_a and set for entity_b. If there is a - corresponding value for entity_a different from None, an + The attributes datatype, unit, value, name and description will only be changed + in entity_a if they are None for entity_a and set for entity_b. If one of those attributes is + set in both entities and they differ, then an EntityMergeConflictError will be raised to inform about an unresolvable merge conflict. @@ -386,8 +517,6 @@ def merge_entities(entity_a: Entity, Returns entity_a. - WARNING: This function is currently experimental and insufficiently tested. Use with care. - Parameters ---------- entity_a, entity_b : Entity @@ -420,12 +549,10 @@ def merge_entities(entity_a: Entity, """ - logger.warning( - "This function is currently experimental and insufficiently tested. Use with care.") - # Compare both entities: - diff_r1, diff_r2 = compare_entities( - entity_a, entity_b, compare_referenced_records=merge_references_with_empty_diffs) + diff_r1, diff_r2 = compare_entities(entity_a, entity_b, + entity_name_id_equivalency=merge_id_with_resolved_entity, + compare_referenced_records=merge_references_with_empty_diffs) # Go through the comparison and try to apply changes to entity_a: for key in diff_r2["parents"]: @@ -445,7 +572,8 @@ def merge_entities(entity_a: Entity, for attribute in ("datatype", "unit", "value"): if (attribute in diff_r2["properties"][key] and diff_r2["properties"][key][attribute] is not None): - if (diff_r1["properties"][key][attribute] is None): + if (attribute not in diff_r1["properties"][key] or + diff_r1["properties"][key][attribute] is None): setattr(entity_a.get_property(key), attribute, diff_r2["properties"][key][attribute]) elif force: @@ -512,43 +640,103 @@ def merge_entities(entity_a: Entity, return entity_a -def describe_diff(olddiff, newdiff, name=None, as_update=True): +def describe_diff(entity0_diff: dict[str, Any], entity1_diff: dict[str, Any], + name: Optional[str] = None, + as_update: Optional[bool] = None, + label_e0: str = "first version", + label_e1: str = "second version", + olddiff: Any = None, + newdiff: Any = None, + ) -> str: + """ + Generate a textual description of the differences between two entities. + These can be generated using :func:`compare_entities` and used within this function like this: + + `describe_diff(*compare_entities(...))` + + Arguments: + ---------- + + entity0_diff: dict[str, Any] + First element of the tuple output of :func:`compare_entities`. + This is referred to as the "first" version. + + entity1_diff: dict[str, Any] + Second element of the tuple output of :func:`compare_entities`. + This is referred to as the "second" version. + + + name: Optional[str] + Default None. Name of the entity that will be shown in the output text. + + as_update: Optional[bool] + Default None. Not used anymore. + + label_e0: str + Can be used to set a custom label for the diff that is associated with the first entity. + + label_e1: str + Can be used to set a custom label for the diff that is associated with the second entity. + + olddiff: Any + Deprecated. Replaced by entity0_diff. + + newdiff: Any + Deprecated. Replaced by entity1_diff. + + Returns: + -------- + A text description of the differences. + + """ description = "" - for attr in list(set(list(olddiff.keys()) + list(newdiff.keys()))): + if as_update: + warnings.warn("'as_update' is deprecated. Do not use it.", DeprecationWarning) + if olddiff: + warnings.warn("'olddiff' is deprecated. Use 'entity0_diff' instead.", DeprecationWarning) + entity0_diff = olddiff + if newdiff: + warnings.warn("'newdiff' is deprecated. Use 'entity1_diff' instead.", DeprecationWarning) + entity1_diff = newdiff + + for attr in list(set(list(entity0_diff.keys()) + list(entity1_diff.keys()))): if attr == "parents" or attr == "properties": continue description += "{} differs:\n".format(attr) - description += "old version: {}\n".format( - olddiff[attr] if attr in olddiff else "not set") - description += "new version: {}\n\n".format( - newdiff[attr] if attr in newdiff else "not set") + description += label_e0 + ": {}\n".format( + entity0_diff[attr] if attr in entity0_diff else "not set") + description += label_e1 + ": {}\n\n".format( + entity1_diff[attr] if attr in entity1_diff else "not set") - if len(olddiff["parents"]) > 0: - description += ("Parents that are only in the old version:\n" - + ", ".join(olddiff["parents"]) + "\n") + if len(entity0_diff["parents"]) > 0: + description += ("Parents that are only in the " + label_e0 + ":\n" + + ", ".join(entity0_diff["parents"]) + "\n") - if len(newdiff["parents"]) > 0: - description += ("Parents that are only in the new version:\n" - + ", ".join(olddiff["parents"]) + "\n") + if len(entity1_diff["parents"]) > 0: + description += ("Parents that are only in the " + label_e1 + ":\n" + + ", ".join(entity0_diff["parents"]) + "\n") - for prop in list(set(list(olddiff["properties"].keys()) - + list(newdiff["properties"].keys()))): + for prop in list(set(list(entity0_diff["properties"].keys()) + + list(entity1_diff["properties"].keys()))): description += "property {} differs:\n".format(prop) - if prop not in olddiff["properties"]: - description += "it does not exist in the old version: \n" - elif prop not in newdiff["properties"]: - description += "it does not exist in the new version: \n" + if prop not in entity0_diff["properties"]: + description += "it does not exist in the " + label_e0 + ":\n" + elif prop not in entity1_diff["properties"]: + description += "it does not exist in the " + label_e1 + ":\n" else: - description += "old version: {}\n".format( - olddiff["properties"][prop]) - description += "new version: {}\n\n".format( - newdiff["properties"][prop]) + description += label_e0 + ": {}\n".format( + entity0_diff["properties"][prop]) + description += label_e1 + ": {}\n\n".format( + entity1_diff["properties"][prop]) if description != "": - description = ("## Difference between the old and the new " - "version of {}\n\n".format(name))+description + description = ("## Difference between the " + + label_e0 + + " and the " + + label_e1 + + " of {}\n\n".format(name)) + description return description diff --git a/src/linkahead/cached.py b/src/linkahead/cached.py index cf1d1d34362335f87c5eca094b5aa9d6b750f68d..11cb959ba10fd507c39eb4d1ddd00bf478859852 100644 --- a/src/linkahead/cached.py +++ b/src/linkahead/cached.py @@ -107,7 +107,7 @@ If a query phrase is given, the result must be unique. If this is not what you def cached_query(query_string: str) -> Container: """A cached version of :func:`linkahead.execute_query<linkahead.common.models.execute_query>`. -All additional arguments are at their default values. + All additional arguments are at their default values. """ result = _cached_access(AccessType.QUERY, query_string, unique=False) @@ -116,7 +116,7 @@ All additional arguments are at their default values. return result -@ lru_cache(maxsize=DEFAULT_SIZE) +@lru_cache(maxsize=DEFAULT_SIZE) def _cached_access(kind: AccessType, value: Union[str, int], unique: bool = True): # This is the function that is actually cached. # Due to the arguments, the cache has kind of separate sections for cached_query and @@ -161,11 +161,12 @@ def cache_clear() -> None: def cache_info(): """Return info about the cache that is used by `cached_query` and `cached_get_entity_by`. -Returns -------- + Returns + ------- -out: named tuple - See the standard library :func:`functools.lru_cache` for details.""" + out: named tuple + See the standard library :func:`functools.lru_cache` for details. + """ return _cached_access.cache_info() @@ -188,21 +189,21 @@ def cache_fill(items: dict[Union[str, int], Any], This allows to fill the cache without actually submitting queries. Note that this does not overwrite existing entries with the same keys. -Parameters ----------- + Parameters + ---------- -items: dict - A dictionary with the entries to go into the cache. The keys must be compatible with the - AccessType given in ``kind`` + items: dict + A dictionary with the entries to go into the cache. The keys must be compatible with the + AccessType given in ``kind`` -kind: AccessType, optional - The AccessType, for example ID, name, path or query. + kind: AccessType, optional + The AccessType, for example ID, name, path or query. -unique: bool, optional - If True, fills the cache for :func:`cached_get_entity_by`, presumably with - :class:`linkahead.Entity<linkahead.common.models.Entity>` objects. If False, the cache should be - filled with :class:`linkahead.Container<linkahead.common.models.Container>` objects, for use with - :func:`cached_query`. + unique: bool, optional + If True, fills the cache for :func:`cached_get_entity_by`, presumably with + :class:`linkahead.Entity<linkahead.common.models.Entity>` objects. If False, the cache should be + filled with :class:`linkahead.Container<linkahead.common.models.Container>` objects, for use with + :func:`cached_query`. """ diff --git a/src/linkahead/common/administration.py b/src/linkahead/common/administration.py index dee341fa84dd85cbd41a77c0e2d510a96f2c4824..28ef107579fccb689b7337aed65e054cfbf36c05 100644 --- a/src/linkahead/common/administration.py +++ b/src/linkahead/common/administration.py @@ -345,20 +345,20 @@ def _get_roles(username, realm=None, **kwargs): def _set_permissions(role, permission_rules, **kwargs): """Set permissions for a role. -Parameters ----------- + Parameters + ---------- -role : str - The role for which the permissions are set. + role : str + The role for which the permissions are set. -permission_rules : iterable<PermissionRule> - An iterable with PermissionRule objects. + permission_rules : iterable<PermissionRule> + An iterable with PermissionRule objects. -**kwargs : - Additional arguments which are passed to the HTTP request. + **kwargs : + Additional arguments which are passed to the HTTP request. -Returns -------- + Returns + ------- None """ xml = etree.Element("PermissionRules") @@ -393,15 +393,15 @@ def _get_permissions(role, **kwargs): class PermissionRule(): """Permission rules. -Parameters ----------- -action : str - Either "grant" or "deny" + Parameters + ---------- + action : str + Either "grant" or "deny" -permission : str - For example ``RETRIEVE:*``. + permission : str + For example ``RETRIEVE:*``. -priority : bool, optional + priority : bool, optional Whether the priority shall be set, defaults is False. """ diff --git a/src/linkahead/common/models.py b/src/linkahead/common/models.py index a8144286fdacefacadf2b823160e0eb9bfe00c77..1dbeb802311c7afaea2340af15e49537520ef57f 100644 --- a/src/linkahead/common/models.py +++ b/src/linkahead/common/models.py @@ -37,26 +37,26 @@ from __future__ import annotations # Can be removed with 3.10. import re import sys +import warnings from builtins import str from copy import deepcopy from datetime import date, datetime +from enum import Enum from functools import cmp_to_key from hashlib import sha512 from os import listdir from os.path import isdir from random import randint from tempfile import NamedTemporaryFile - -from typing import TYPE_CHECKING -from typing import Any, Final, Literal, Optional, TextIO, Union +from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TextIO, Union if TYPE_CHECKING: - from .datatype import DATATYPE - from tempfile import _TemporaryFileWrapper from io import BufferedWriter from os import PathLike - QueryDict = dict[str, Optional[str]] + from tempfile import _TemporaryFileWrapper + from .datatype import DATATYPE + QueryDict = dict[str, Optional[str]] from warnings import warn @@ -65,36 +65,17 @@ from lxml import etree from ..configuration import get_config from ..connection.connection import get_connection from ..connection.encode import MultipartParam, multipart_encode -from ..exceptions import ( - AmbiguousEntityError, - AuthorizationError, - ConsistencyError, - EmptyUniqueQueryError, - EntityDoesNotExistError, - EntityError, - EntityHasNoAclError, - EntityHasNoDatatypeError, - HTTPURITooLongError, - LinkAheadConnectionError, - LinkAheadException, - MismatchingEntitiesError, - PagingConsistencyError, - QueryNotUniqueError, - TransactionError, - UniqueNamesError, - UnqualifiedParentsError, - UnqualifiedPropertiesError, -) -from .datatype import ( - BOOLEAN, - DATETIME, - DOUBLE, - INTEGER, - TEXT, - get_list_datatype, - is_list_datatype, - is_reference, -) +from ..exceptions import (AmbiguousEntityError, AuthorizationError, + ConsistencyError, EmptyUniqueQueryError, + EntityDoesNotExistError, EntityError, + EntityHasNoAclError, EntityHasNoDatatypeError, + HTTPURITooLongError, LinkAheadConnectionError, + LinkAheadException, MismatchingEntitiesError, + PagingConsistencyError, QueryNotUniqueError, + TransactionError, 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 from .utils import uuid, xml2str @@ -114,8 +95,8 @@ if TYPE_CHECKING: IMPORTANCE = Literal["OBLIGATORY", "RECOMMENDED", "SUGGESTED", "FIX", "NONE"] ROLE = Literal["Entity", "Record", "RecordType", "Property", "File"] -SPECIAL_ATTRIBUTES = ["name", "role", "datatype", "description", - "id", "path", "checksum", "size", "value"] +SPECIAL_ATTRIBUTES = ["name", "role", "datatype", "description", "file", + "id", "path", "checksum", "size", "value", "unit"] class Entity: @@ -138,10 +119,10 @@ class Entity: description: Optional[str] = None, # @ReservedAssignment datatype: Optional[DATATYPE] = None, value=None, - **kwargs, + role=None, ): - self.__role: Optional[ROLE] = kwargs["role"] if "role" in kwargs else None + self.__role: Optional[ROLE] = role self._checksum: Optional[str] = None self._size = None self._upload = None @@ -156,8 +137,8 @@ class Entity: self.datatype: Optional[DATATYPE] = datatype self.value = value self.messages = Messages() - self.properties = _Properties() - self.parents = _ParentList() + self.properties = PropertyList() + self.parents = ParentList() self.path: Optional[str] = None self.file: Optional[File] = None self.unit: Optional[str] = None @@ -873,29 +854,29 @@ class Entity: check. Note that, if checked, name or ID should not be None, lest the check fail. -Parameters ----------- + Parameters + ---------- -parent: Entity - Check for this parent. + parent: Entity + Check for this parent. -recursive: bool, optional - Whether to check recursively. + recursive: bool, optional + Whether to check recursively. -check_name: bool, optional - Whether to use the name for ancestry check. + check_name: bool, optional + Whether to use the name for ancestry check. -check_id: bool, optional - Whether to use the ID for ancestry check. + check_id: bool, optional + Whether to use the ID for ancestry check. -retrieve: bool, optional - If False, do not retrieve parents from the server. + retrieve: bool, optional + If False, do not retrieve parents from the server. -Returns -------- -out: bool - True if ``parent`` is a true parent, False otherwise. -""" + Returns + ------- + out: bool + True if ``parent`` is a true parent, False otherwise. + """ if recursive: parents = self.get_parents_recursively(retrieve=retrieve) @@ -922,7 +903,7 @@ out: bool def get_parents(self): """Get all parents of this entity. - @return: _ParentList(list) + @return: ParentList(list) """ return self.parents @@ -930,17 +911,17 @@ out: bool def get_parents_recursively(self, retrieve: bool = True) -> list[Entity]: """Get all ancestors of this entity. -Parameters ----------- + Parameters + ---------- -retrieve: bool, optional - If False, do not retrieve parents from the server. + retrieve: bool, optional + If False, do not retrieve parents from the server. -Returns -------- -out: list[Entity] - The parents of this Entity -""" + Returns + ------- + out: list[Entity] + The parents of this Entity + """ all_parents: list[Entity] = [] self._get_parent_recursively(all_parents, retrieve=retrieve) @@ -1022,7 +1003,7 @@ out: list[Entity] def get_properties(self): """Get all properties of this entity. - @return: _Properties(list) + @return: PropertyList(list) """ return self.properties @@ -1598,15 +1579,15 @@ out: list[Entity] unique=True, flags=None, sync=True): """Update this entity. -There are two possible work-flows to perform this update: -First: - 1) retrieve an entity - 2) do changes - 3) call update method + There are two possible work-flows to perform this update: + First: + 1) retrieve an entity + 2) do changes + 3) call update method -Second: - 1) construct entity with id - 2) call update method. + Second: + 1) construct entity with id + 2) call update method. For slight changes the second one it is more comfortable. Furthermore, it is possible to stay off-line until calling the update method. The name, description, unit, datatype, path, @@ -2422,11 +2403,14 @@ class File(Record): value=value, unit=unit, importance=importance, inheritance=inheritance) -class _Properties(list): - """FIXME: Add docstring.""" +class PropertyList(list): + """A list class for Property objects + + This class provides addional functionality like get/set_importance or get_by_name. + """ def __init__(self): - list.__init__(self) + super().__init__() self._importance: dict[Entity, IMPORTANCE] = dict() self._inheritance: dict[Entity, INHERITANCE] = dict() self._element_by_name: dict[str, Entity] = dict() @@ -2519,6 +2503,40 @@ class _Properties(list): return xml2str(xml) + def filter(self, prop: Optional[Property] = None, + pid: Union[None, str, int] = None, + name: Optional[str] = None, + conjunction: bool = False) -> list: + """ + Return all Properties from the given PropertyList that match the + selection criteria. + + Please refer to the documentation of _filter_entity_list for a detailed + description of behaviour. + + Params + ------ + listobject : Iterable(Property) + List to be filtered + prop : Property + Property to match name and ID with. Cannot be set + simultaneously with ID or name. + pid : str, int + Property ID to match + name : str + Property name to match + conjunction : bool, defaults to False + Set to return only entities that match both id and name + if both are given. + + Returns + ------- + matches : list + List containing all matching Properties + """ + return _filter_entity_list(self, pid=pid, name=name, entity=prop, + conjunction=conjunction) + def _get_entity_by_cuid(self, cuid: str): ''' Get the first entity which has the given cuid. @@ -2576,9 +2594,7 @@ class _Properties(list): raise KeyError(str(prop) + " not found.") -class _ParentList(list): - # TODO unclear why this class is private. Isn't it use full for users? - +class ParentList(list): def _get_entity_by_cuid(self, cuid): ''' Get the first entity which has the given cuid. @@ -2593,8 +2609,8 @@ class _ParentList(list): return e raise KeyError("No entity with that cuid in this container.") - def __init__(self): - list.__init__(self) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self._element_by_name = dict() self._element_by_id = dict() @@ -2607,15 +2623,9 @@ class _ParentList(list): if isinstance(parent, list): for p in parent: self.append(p) - return if isinstance(parent, Entity): - if parent.id: - self._element_by_id[str(parent.id)] = parent - - if parent.name: - self._element_by_name[parent.name] = parent list.append(self, parent) else: raise TypeError("Argument was not an Entity") @@ -2657,7 +2667,55 @@ class _ParentList(list): return xml2str(xml) + def filter(self, parent: Optional[Parent] = None, + pid: Union[None, str, int] = None, + name: Optional[str] = None, + conjunction: bool = False) -> list: + """ + Return all Parents from the given ParentList that match the selection + criteria. + + Please refer to the documentation of _filter_entity_list for a detailed + description of behaviour. + + Params + ------ + listobject : Iterable(Parent) + List to be filtered + parent : Parent + Parent to match name and ID with. Cannot be set + pid : str, int + Parent ID to match + name : str + Parent name to match + simultaneously with ID or name. + conjunction : bool, defaults to False + Set to return only entities that match both id and name + if both are given. + + Returns + ------- + matches : list + List containing all matching Parents + """ + return _filter_entity_list(self, pid=pid, name=name, entity=parent, + conjunction=conjunction) + def remove(self, parent: Union[Entity, int, str]): + """ + Remove first occurrence of parent. + + Parameters + ---------- + parent: Union[Entity, int, str], the parent to be removed identified via ID or name. If a + Parent object is provided the ID and then the name is used to identify the parent to be + removed. + + Returns + ------- + None + """ + if isinstance(parent, Entity): if parent in self: list.remove(self, parent) @@ -2675,11 +2733,11 @@ class _ParentList(list): # by name for e in self: - if e.name is not None and e.name == parent.name: + if e.name is not None and e.name.lower() == parent.name.lower(): list.remove(self, e) return - elif hasattr(parent, "encode"): + elif isinstance(parent, str): # by name for e in self: @@ -2698,6 +2756,19 @@ class _ParentList(list): raise KeyError(str(parent) + " not found.") +class _Properties(PropertyList): + def __init__(self, *args, **kwargs): + warnings.warn(DeprecationWarning("This class is deprecated. Please use PropertyList.")) + super().__init__(*args, **kwargs) + + +class _ParentList(ParentList): + def __init__(self, *args, **kwargs): + warnings.warn(DeprecationWarning("This class is deprecated. Please use ParentList " + "(without underscore).")) + super().__init__(*args, **kwargs) + + class Messages(list): """This specialization of list stores error, warning, info, and other messages. The mentioned three messages types play a special role. @@ -5392,3 +5463,91 @@ def delete(ids: Union[list[int], range], raise_exception_on_error: bool = True): c.append(Entity(id=ids)) return c.delete(raise_exception_on_error=raise_exception_on_error) + + +def _filter_entity_list(listobject: list[Entity], + entity: Optional[Entity] = None, + pid: Union[None, str, int] = None, + name: Optional[str] = None, + conjunction: bool = False) -> list: + """ + Returns a subset of entities from the list based on whether their id and + name matches the selection criterion. + + If both pid and name are given, entities from the list are first matched + based on id. If they do not have an id, they are matched based on name. + If only one parameter is given, only this parameter is considered. + + If an Entity is given, neither name nor ID may be set. In this case, pid + and name are determined by the attributes of given entity. + + This results in the following selection criteria: + If an entity in the list + - has both name and id, it is returned if the id matches the given not-None + value for pid. If no pid was given, it is returned if the name matches. + - has an id, but no name, it will be returned only if it matches the given + not-None value + - has no id, but a name, it will be returned if the name matches the given + not-None value + - has neither id nor name, it will never be returned + + As IDs can be strings, integer IDs are cast to string for the comparison. + + Params + ------ + listobject : Iterable(Entity) + List to be filtered + entity : Entity + Entity to match name and ID for. Cannot be set + simultaneously with ID or name. + pid : str, int + Entity ID to match + name : str + Entity name to match + conjunction : bool, defaults to False + Set to true to return only entities that match both id + and name if both are given. + + Returns + ------- + matches : list + A List containing all matching Entities + """ + # Check correct input params and setup + if entity is not None: + if pid is not None or name is not None: + raise ValueError("If an entity is given, pid and name must not be set.") + pid = entity.id + name = entity.name + if pid is None and name is None: + if entity is None: + raise ValueError("One of entity, pid or name must be set.") + else: + raise ValueError("A given entity must have at least one of name and id.") + if pid is None or name is None: + conjunction = False + + # Iterate through list and match based on given criteria + matches = [] + for candidate in listobject: + name_match, pid_match = False, False + + # Check whether name/pid match + # Comparison is only possible if both are not None + pid_none = pid is None or candidate.id is None + # Cast to string in case one is f.e. "12" and the other is 12 + if not pid_none and str(candidate.id) == str(pid): + pid_match = True + name_none = name is None or candidate.name is None + if not name_none and str(candidate.name).lower() == str(name).lower(): + name_match = True + + # If the criteria are satisfied, append the match. + if pid_match and name_match: + matches.append(candidate) + elif not conjunction: + if pid_match: + matches.append(candidate) + if pid_none and name_match: + matches.append(candidate) + return matches diff --git a/src/linkahead/common/versioning.py b/src/linkahead/common/versioning.py index 2e292e6bb031725fbd6da618c4b888c05072c46b..11cf5f6904b02954eb0b2bddc16478590df167e7 100644 --- a/src/linkahead/common/versioning.py +++ b/src/linkahead/common/versioning.py @@ -105,7 +105,7 @@ class Version(): is_head: Union[bool, str, None] = False, is_complete_history: Union[bool, str, None] = False): """Typically the `predecessors` or `successors` should not "link back" to an existing Version -object.""" + object.""" self.id = id self.date = date self.username = username diff --git a/src/linkahead/utils/create_revision.py b/src/linkahead/utils/create_revision.py index 5f6ecc8148859d0ee0908412ff80d20d465cdb25..cde4bae5b0d919977d220b2c35896dcb20e933e7 100644 --- a/src/linkahead/utils/create_revision.py +++ b/src/linkahead/utils/create_revision.py @@ -34,15 +34,15 @@ def bend_references(from_id, to_id, except_for=None): and those references are changed to point to to_id. entities having an id listed in except_for are excluded. -Parameters ----------- + Parameters + ---------- -from_id : int - the old object to which references where pointing -to_id : int - the new object to which references will be pointing -except_for : list of int - entities with id of this list will not be changed + from_id : int + the old object to which references where pointing + to_id : int + the new object to which references will be pointing + except_for : list of int + entities with id of this list will not be changed """ if except_for is None: except_for = [to_id] @@ -73,16 +73,16 @@ def create_revision(old_id, prop, value): This function changes the record with id old_id. The value of the propertye prop is changed to value. -Parameters ----------- + Parameters + ---------- -old_id : int - id of the record to be changed -prop : string - name of the property to be changed -value : type of corresponding property - the new value of the corresponding property -""" + old_id : int + id of the record to be changed + prop : string + name of the property to be changed + value : type of corresponding property + the new value of the corresponding property + """ record = db.execute_query("FIND {}".format(old_id))[0] new_rec = record.copy() new_rec.get_property(prop).value = value diff --git a/src/linkahead/utils/get_entity.py b/src/linkahead/utils/get_entity.py index 0ffd89e4dc7f214bbc72d4508f6ca4481dad7d9c..dd91cdc27b3f6adb52ddef36a59d1a0965fb662e 100644 --- a/src/linkahead/utils/get_entity.py +++ b/src/linkahead/utils/get_entity.py @@ -30,13 +30,13 @@ from .escape import escape_squoted_text def get_entity_by_name(name: str, role: Optional[str] = None) -> Entity: """Return the result of a unique query that uses the name to find the correct entity. -Submits the query "FIND {role} WITH name='{name}'". + Submits the query "FIND {role} WITH name='{name}'". -Parameters ----------- + Parameters + ---------- -role: str, optional - The role for the query, defaults to ``ENTITY``. + role: str, optional + The role for the query, defaults to ``ENTITY``. """ name = escape_squoted_text(name) if role is None: @@ -48,13 +48,13 @@ role: str, optional def get_entity_by_id(eid: Union[str, int], role: Optional[str] = None) -> Entity: """Return the result of a unique query that uses the id to find the correct entity. -Submits the query "FIND {role} WITH id='{eid}'". + Submits the query "FIND {role} WITH id='{eid}'". -Parameters ----------- + Parameters + ---------- -role: str, optional - The role for the query, defaults to ``ENTITY``. + role: str, optional + The role for the query, defaults to ``ENTITY``. """ if role is None: role = "ENTITY" @@ -65,13 +65,13 @@ role: str, optional def get_entity_by_path(path: str) -> Entity: """Return the result of a unique query that uses the path to find the correct file. -Submits the query "FIND {role} WHICH IS STORED AT '{path}'". + Submits the query "FIND {role} WHICH IS STORED AT '{path}'". -Parameters ----------- + Parameters + ---------- -role: str, optional - The role for the query, defaults to ``ENTITY``. + role: str, optional + The role for the query, defaults to ``ENTITY``. """ # type hint can be ignored, it's a unique query return execute_query(f"FIND FILE WHICH IS STORED AT '{path}'", unique=True) # type: ignore diff --git a/src/linkahead/utils/linkahead_admin.py b/src/linkahead/utils/linkahead_admin.py index f7e3b8b63f18e37e6210f2aa03f34ce5b0f688d4..ca5f3c01e0bbe95fe712761ec7f443ec88d406fd 100755 --- a/src/linkahead/utils/linkahead_admin.py +++ b/src/linkahead/utils/linkahead_admin.py @@ -33,7 +33,7 @@ from argparse import ArgumentParser, RawDescriptionHelpFormatter import linkahead as db from linkahead import administration as admin -from linkahead.exceptions import HTTPClientError +from linkahead.exceptions import HTTPClientError, HTTPResourceNotFoundError, HTTPForbiddenError __all__ = [] __version__ = 0.3 @@ -42,19 +42,42 @@ __updated__ = '2018-12-11' def do_update_role(args): - admin._update_role(name=args.role_name, description=args.role_description) + """ + Update the description of a role. + + Allowed keyword arguments: + role_name: Name of the role to update + role_description: New description of the role + """ + try: + admin._update_role(name=args.role_name, description=args.role_description) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot update role '{args.role_name}', " + f"reason: '{e.msg}'") def do_create_role(args): - admin._insert_role(name=args.role_name, description=args.role_description) + try: + admin._insert_role(name=args.role_name, description=args.role_description) + except (HTTPClientError, HTTPForbiddenError) as e: + print(f"Error: Cannot create role '{args.role_name}', " + f"reason: '{e.msg}'") def do_retrieve_role(args): - print(admin._retrieve_role(name=args.role_name)) + try: + print(admin._retrieve_role(name=args.role_name)) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot retrieve role '{args.role_name}', " + f"reason: '{e.msg}'") def do_delete_role(args): - admin._delete_role(name=args.role_name) + try: + admin._delete_role(name=args.role_name) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot delete role '{args.role_name}', " + f"reason: '{e.msg}'") def do_retrieve(args): @@ -123,25 +146,27 @@ def do_create_user(args): try: admin._insert_user(name=args.user_name, email=args.user_email, password=password) - if args.activate_user: do_activate_user(args) - except HTTPClientError as e: - print(e.msg) + except (HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot create user '{args.user_name}', " + f"reason: '{e.msg}'") def do_activate_user(args): try: admin._update_user(name=args.user_name, status="ACTIVE") - except HTTPClientError as e: - print(e.msg) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot activate user '{args.user_name}', " + f"reason: '{e.msg}'") def do_deactivate_user(args): try: admin._update_user(name=args.user_name, status="INACTIVE") - except HTTPClientError as e: - print(e.msg) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot deactivate user '{args.user_name}', " + f"reason: '{e.msg}'") def do_set_user_password(args): @@ -150,58 +175,110 @@ def do_set_user_password(args): else: password = args.user_password try: - admin._update_user(name=args.user_name, password=password) - except HTTPClientError as e: - print(e.msg) + admin._update_user(name=args.user_name, password=password, realm=args.realm) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot set password for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_add_user_roles(args): - roles = admin._get_roles(username=args.user_name, realm=None) + try: + roles = admin._get_roles(username=args.user_name, realm=None) + except (HTTPForbiddenError, HTTPResourceNotFoundError) as e: + print(f"Error: Cannot access roles for user '{args.user_name}', " + f"reason: '{e.msg}'") + return for r in args.user_roles: roles.add(r) - admin._set_roles(username=args.user_name, roles=roles) + try: + admin._set_roles(username=args.user_name, roles=roles) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot add new roles for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_remove_user_roles(args): - roles = admin._get_roles(username=args.user_name, realm=None) + try: + roles = admin._get_roles(username=args.user_name, realm=None) + except (HTTPForbiddenError, HTTPResourceNotFoundError) as e: + print(f"Error: Cannot access roles for user '{args.user_name}', " + f"reason: '{e.msg}'") + return for r in args.user_roles: if r in roles: roles.remove(r) - admin._set_roles(username=args.user_name, roles=roles) + try: + admin._set_roles(username=args.user_name, roles=roles) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot remove roles from user '{args.user_name}', " + f"reason: '{e.msg}'") def do_set_user_entity(args): - admin._update_user(name=args.user_name, entity=args.user_entity) + try: + admin._update_user(name=args.user_name, entity=args.user_entity) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot set entity for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_reset_user_entity(args): - admin._update_user(name=args.user_name, entity="") + try: + admin._update_user(name=args.user_name, entity="") + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot remove entity for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_set_user_email(args): - admin._update_user(name=args.user_name, email=args.user_email) + try: + admin._update_user(name=args.user_name, email=args.user_email) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot set email for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_retrieve_user(args): - print(admin._retrieve_user(name=args.user_name)) + try: + print(admin._retrieve_user(name=args.user_name)) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot retrieve user '{args.user_name}', " + f"reason: '{e.msg}'") def do_delete_user(args): - admin._delete_user(name=args.user_name) + try: + admin._delete_user(name=args.user_name) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot delete user '{args.user_name}', " + f"reason: '{e.msg}'") def do_retrieve_user_roles(args): - print(admin._get_roles(username=args.user_name)) + try: + print(admin._get_roles(username=args.user_name)) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot retrieve roles for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_retrieve_role_permissions(args): - print(admin._get_permissions(role=args.role_name)) + try: + print(admin._get_permissions(role=args.role_name)) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot retrieve permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") def do_grant_role_permissions(args): - perms = admin._get_permissions(args.role_name) + try: + perms = admin._get_permissions(role=args.role_name) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot access permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") + return for p in args.role_permissions: g = admin.PermissionRule( @@ -215,11 +292,20 @@ def do_grant_role_permissions(args): if d in perms: perms.remove(d) perms.add(g) - admin._set_permissions(role=args.role_name, permission_rules=perms) + try: + admin._set_permissions(role=args.role_name, permission_rules=perms) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot set permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") def do_revoke_role_permissions(args): - perms = admin._get_permissions(args.role_name) + try: + perms = admin._get_permissions(role=args.role_name) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot access permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") + return for p in args.role_permissions: g = admin.PermissionRule( @@ -232,11 +318,20 @@ def do_revoke_role_permissions(args): if d in perms: perms.remove(d) - admin._set_permissions(role=args.role_name, permission_rules=perms) + try: + admin._set_permissions(role=args.role_name, permission_rules=perms) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot revoke permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") def do_deny_role_permissions(args): - perms = admin._get_permissions(args.role_name) + try: + perms = admin._get_permissions(role=args.role_name) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot access permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") + return for p in args.role_permissions: g = admin.PermissionRule( @@ -250,7 +345,11 @@ def do_deny_role_permissions(args): if d in perms: perms.remove(d) perms.add(d) - admin._set_permissions(role=args.role_name, permission_rules=perms) + try: + admin._set_permissions(role=args.role_name, permission_rules=perms) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot deny permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") def do_retrieve_entity_acl(args): @@ -364,6 +463,12 @@ USAGE metavar='USERNAME', dest="user_name", help="The name of the user who's password is to be set.") + subparser.add_argument( + metavar='REALM', + dest="realm", + nargs="?", + default=None, + help="The realm of the user who's password is to be set.") subparser.add_argument( metavar='PASSWORD', nargs="?", diff --git a/tox.ini b/tox.ini index 592c660c5bbbf5805a3ecbb3e60c41f597182a55..c63555c27b73224106109c1f675e9525d9e89b74 100644 --- a/tox.ini +++ b/tox.ini @@ -17,6 +17,6 @@ max-line-length=100 [pytest] testpaths = unittests xfail_strict = True -addopts = -x -vv --cov=caosdb +addopts = -x -vv --cov=linkahead pythonpath = src diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index 4705f19a1bdfbc4358790f787f2dce9ea97fee48..fdd5adda065a563b15008f1b840539c110921b65 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -1,6 +1,7 @@ # # This file is a part of the LinkAhead Project. # +# Copyright (C) 2024 Alexander Schlemmer <a.schlemmer@indiscale.com> # Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> # Copyright (C) 2022 Florian Spreckelsen <f.spreckelsen@indiscale.com> # Copyright (C) 2022 Daniel Hornung <d.hornung@indiscale.com> @@ -25,13 +26,15 @@ # Test apiutils # A. Schlemmer, 02/2018 +from io import StringIO import linkahead as db import linkahead.apiutils import pytest from linkahead.apiutils import (EntityMergeConflictError, apply_to_ids, compare_entities, create_id_query, empty_diff, - merge_entities, resolve_reference) + merge_entities, resolve_reference, + describe_diff) from linkahead.common.models import SPECIAL_ATTRIBUTES @@ -96,6 +99,7 @@ def test_resolve_reference(): def test_compare_entities(): + # test compare of parents, properties r1 = db.Record() r2 = db.Record() r1.add_parent("bla") @@ -111,13 +115,27 @@ def test_compare_entities(): r2.add_property("tester", ) r1.add_property("tests_234234", value=45) r2.add_property("tests_TT", value=45) + r1.add_property("datatype", value=45, datatype=db.INTEGER) + r2.add_property("datatype", value=45) + r1.add_property("entity_id", value=2) + r2.add_property("entity_id", value=24) + r1.add_property("entity_mix_e", value=2) + r2.add_property("entity_mix_e", value=db.Entity(id=2)) + r1.add_property("entity_mix_d", value=22) + r2.add_property("entity_mix_d", value=db.Entity(id=2)) + r1.add_property("entity_mix_w", value=22) + r2.add_property("entity_mix_w", value=db.Entity()) + r1.add_property("entity_Ent_e", value=db.Entity(id=2)) + r2.add_property("entity_Ent_e", value=db.Entity(id=2)) + r1.add_property("entity_Ent_d", value=db.Entity(id=2)) + r2.add_property("entity_Ent_d", value=db.Entity(id=22)) diff_r1, diff_r2 = compare_entities(r1, r2) assert len(diff_r1["parents"]) == 1 assert len(diff_r2["parents"]) == 0 - assert len(diff_r1["properties"]) == 4 - assert len(diff_r2["properties"]) == 4 + assert len(diff_r1["properties"]) == 11 + assert len(diff_r2["properties"]) == 11 assert "test" not in diff_r1["properties"] assert "test" not in diff_r2["properties"] @@ -134,13 +152,89 @@ def test_compare_entities(): assert "tests_234234" in diff_r1["properties"] assert "tests_TT" in diff_r2["properties"] + assert "datatype" in diff_r1["properties"] + assert "datatype" in diff_r1["properties"]["datatype"] + assert "datatype" in diff_r2["properties"] + assert "datatype" in diff_r2["properties"]["datatype"] + + assert "entity_id" in diff_r1["properties"] + assert "entity_id" in diff_r2["properties"] + + assert "entity_mix_e" in diff_r1["properties"] + assert "entity_mix_e" in diff_r2["properties"] + assert "entity_Ent_e" in diff_r1["properties"] + assert "entity_Ent_e" in diff_r2["properties"] + + assert "entity_mix_d" in diff_r1["properties"] + assert "entity_mix_d" in diff_r2["properties"] + assert "entity_mix_w" in diff_r1["properties"] + assert "entity_mix_w" in diff_r2["properties"] + assert "entity_Ent_d" in diff_r1["properties"] + assert "entity_Ent_d" in diff_r2["properties"] + + diff_r1, diff_r2 = compare_entities(r1, r2, compare_referenced_records=True) + + assert len(diff_r1["parents"]) == 1 + assert len(diff_r2["parents"]) == 0 + assert len(diff_r1["properties"]) == 10 + assert len(diff_r2["properties"]) == 10 + + assert "entity_id" in diff_r1["properties"] + assert "entity_id" in diff_r2["properties"] + + assert "entity_mix_e" in diff_r1["properties"] + assert "entity_mix_e" in diff_r2["properties"] + assert "entity_mix_w" in diff_r1["properties"] + assert "entity_mix_w" in diff_r2["properties"] + assert "entity_Ent_e" not in diff_r1["properties"] + assert "entity_Ent_e" not in diff_r2["properties"] + + assert "entity_mix_d" in diff_r1["properties"] + assert "entity_mix_d" in diff_r2["properties"] + assert "entity_Ent_d" in diff_r1["properties"] + assert "entity_Ent_d" in diff_r2["properties"] + + diff_r1, diff_r2 = compare_entities(r1, r2, + entity_name_id_equivalency=True, + compare_referenced_records=True) + + assert len(diff_r1["parents"]) == 1 + assert len(diff_r2["parents"]) == 0 + assert len(diff_r1["properties"]) == 9 + assert len(diff_r2["properties"]) == 9 + + assert "entity_id" in diff_r1["properties"] + assert "entity_id" in diff_r2["properties"] + + assert "entity_mix_e" not in diff_r1["properties"] + assert "entity_mix_e" not in diff_r2["properties"] + assert "entity_mix_w" in diff_r1["properties"] + assert "entity_mix_w" in diff_r2["properties"] + assert "entity_Ent_e" not in diff_r1["properties"] + assert "entity_Ent_e" not in diff_r2["properties"] + + assert "entity_mix_d" in diff_r1["properties"] + assert "entity_mix_d" in diff_r2["properties"] + assert "entity_Ent_d" in diff_r1["properties"] + assert "entity_Ent_d" in diff_r2["properties"] + + r1 = db.Record() + r2 = db.Record() + r1.add_property(id=20, name="entity_mix_d", value=2, datatype=db.LIST("B")) + r2.add_property("entity_mix_d", value=db.Entity()) + + diff_r1, diff_r2 = compare_entities(r1, r2, compare_referenced_records=True) + + assert len(diff_r1["properties"]) == 1 + assert len(diff_r2["properties"]) == 1 + + assert "entity_mix_d" in diff_r1["properties"] + assert "entity_mix_d" in diff_r2["properties"] + def test_compare_entities_units(): r1 = db.Record() r2 = db.Record() - r1.add_parent("bla") - r2.add_parent("bla") - r1.add_parent("lopp") r1.add_property("test", value=2, unit="cm") r2.add_property("test", value=2, unit="m") r1.add_property("tests", value=3, unit="cm") @@ -152,8 +246,6 @@ def test_compare_entities_units(): diff_r1, diff_r2 = compare_entities(r1, r2) - assert len(diff_r1["parents"]) == 1 - assert len(diff_r2["parents"]) == 0 assert len(diff_r1["properties"]) == 4 assert len(diff_r2["properties"]) == 4 @@ -170,14 +262,229 @@ def test_compare_entities_units(): assert diff_r2["properties"]["test"]["unit"] == "m" +def test_compare_entities_battery(): + par1, par3 = db.Record(name=""), db.RecordType(name="") + r1, r2, r3 = db.Record(), db.Record(), db.Record() + prop2 = db.Property(name="Property 2") + prop3 = db.Property(name="") + + # Basic tests for Properties + prop_settings = {"datatype": db.REFERENCE, "description": "desc of prop", + "value": db.Record().add_parent(par3), "unit": '°'} + t1 = db.Record().add_parent(db.RecordType(id=1)) + t2 = db.Record().add_parent(db.RecordType(id=1)) + # Change datatype + t1.add_property(db.Property(name="datatype", **prop_settings)) + prop_settings["datatype"] = par3 + t2.add_property(db.Property(name="datatype", **prop_settings)) + # Change description + t1.add_property(db.Property(name="description", **prop_settings)) + prop_settings["description"] = "diff desc" + t2.add_property(db.Property(name="description", **prop_settings)) + # Change value to copy + t1.add_property(db.Property(name="value copy", **prop_settings)) + prop_settings["value"] = db.Record().add_parent(par3) + t2.add_property(db.Property(name="value copy", **prop_settings)) + # Change value to something different + t1.add_property(db.Property(name="value", **prop_settings)) + prop_settings["value"] = db.Record(name="n").add_parent(par3) + t2.add_property(db.Property(name="value", **prop_settings)) + # Change unit + t1.add_property(db.Property(name="unit", **prop_settings)) + prop_settings["unit"] = db.Property(unit='°') + t2.add_property(db.Property(name="unit", **prop_settings)) + # Change unit again + t1.add_property(db.Property(name="unit 2", **prop_settings)) + prop_settings["unit"] = db.Property() + t2.add_property(db.Property(name="unit 2", **prop_settings)) + # Compare + diff_0 = compare_entities(t1, t2) + diff_1 = compare_entities(t1, t2, compare_referenced_records=True) + # Check correct detection of changes + assert diff_0[0]["properties"]["datatype"] == {"datatype": db.REFERENCE} + assert diff_0[1]["properties"]["datatype"] == {"datatype": par3} + assert diff_0[0]["properties"]["description"] == {"description": "desc of prop"} + assert diff_0[1]["properties"]["description"] == {"description": "diff desc"} + assert "value" in diff_0[0]["properties"]["value copy"] + assert "value" in diff_0[1]["properties"]["value copy"] + assert "value" in diff_0[0]["properties"]["value"] + assert "value" in diff_0[1]["properties"]["value"] + assert "unit" in diff_0[0]["properties"]["unit"] + assert "unit" in diff_0[1]["properties"]["unit"] + assert "unit" in diff_0[0]["properties"]["unit 2"] + assert "unit" in diff_0[1]["properties"]["unit 2"] + # Check correct result for compare_referenced_records=True + assert "value copy" not in diff_1[0]["properties"] + assert "value copy" not in diff_1[1]["properties"] + diff_0[0]["properties"].pop("value copy") + diff_0[1]["properties"].pop("value copy") + assert diff_0 == diff_1 + + # Basic tests for Parents + t3 = db.Record().add_parent(db.RecordType("A")).add_parent(db.Record("B")) + t4 = db.Record().add_parent(db.RecordType("A")) + assert compare_entities(t3, t4)[0]['parents'] == ['B'] + assert len(compare_entities(t3, t4)[1]['parents']) == 0 + t4.add_parent(db.Record("B")) + assert empty_diff(t3, t4) + # The two following assertions document current behaviour but do not make a + # lot of sense + t4.add_parent(db.Record("B")) + assert empty_diff(t3, t4) + t3.add_parent(db.RecordType("A")).add_parent(db.Record("B")) + t4.add_parent(db.RecordType("B")).add_parent(db.Record("A")) + assert empty_diff(t3, t4) + + # Basic tests for special attributes + prop_settings = {"id": 42, "name": "Property", + "datatype": db.LIST(db.REFERENCE), "value": [db.Record(name="")], + "unit": '€', "description": "desc of prop"} + alt_settings = {"id": 64, "name": "Property 2", + "datatype": db.LIST(db.TEXT), "value": [db.RecordType(name="")], + "unit": '€€', "description": " ę Ě ப ཾ ཿ ∛ ∜ ㅿ ㆀ 값 "} + t5 = db.Property(**prop_settings) + t6 = db.Property(**prop_settings) + assert empty_diff(t5, t6) + # ID + t5.id = alt_settings['id'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'id': alt_settings['id']} + assert diff[1] == {'properties': {}, 'parents': [], 'id': prop_settings['id']} + t6.id = alt_settings['id'] + assert empty_diff(t5, t6) + # Name + t5.name = alt_settings['name'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'name': alt_settings['name']} + assert diff[1] == {'properties': {}, 'parents': [], 'name': prop_settings['name']} + t6.name = alt_settings['name'] + assert empty_diff(t5, t6) + # Description + t6.description = alt_settings['description'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'description': prop_settings['description']} + assert diff[1] == {'properties': {}, 'parents': [], 'description': alt_settings['description']} + t5.description = alt_settings['description'] + assert empty_diff(t5, t6) + # Unit + t5.unit = alt_settings['unit'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'unit': alt_settings['unit']} + assert diff[1] == {'properties': {}, 'parents': [], 'unit': prop_settings['unit']} + t6.unit = alt_settings['unit'] + assert empty_diff(t5, t6) + # Value + t6.value = alt_settings['value'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'value': prop_settings['value']} + assert diff[1] == {'properties': {}, 'parents': [], 'value': alt_settings['value']} + t5.value = alt_settings['value'] + assert empty_diff(t5, t6) + # Datatype + t6.datatype = alt_settings['datatype'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'datatype': prop_settings['datatype']} + assert diff[1] == {'properties': {}, 'parents': [], 'datatype': alt_settings['datatype']} + t5.datatype = alt_settings['datatype'] + assert empty_diff(t5, t6) + # All at once + diff = compare_entities(db.Property(**prop_settings), db.Property(**alt_settings)) + assert diff[0] == {'properties': {}, 'parents': [], **prop_settings} + assert diff[1] == {'properties': {}, 'parents': [], **alt_settings} + # Entity Type + diff = compare_entities(db.Property(value=db.Property(id=101)), + db.Property(value=db.Record(id=101))) + assert "value" in diff[0] + assert "value" in diff[1] + diff = compare_entities(db.Property(value=db.Record(id=101)), + db.Property(value=db.Record(id=101))) + assert "value" in diff[0] + assert "value" in diff[1] + assert empty_diff(db.Property(value=db.Record(id=101)), + db.Property(value=db.Record(id=101)), + compare_referenced_records=True) + + # Special cases + # Files + assert not empty_diff(db.File(path='ABC', file=StringIO("ABC")), + db.File(path='ABC', file=StringIO("Other"))) + # Importance + assert empty_diff(db.Property().add_property(prop2), + db.Property().add_property(prop2)) + assert not empty_diff(db.Property().add_property(prop2, importance=db.SUGGESTED), + db.Property().add_property(prop2, importance=db.OBLIGATORY)) + # Mixed Lists + assert empty_diff(db.Property(value=[1, 2, 'a', r1]), + db.Property(value=[1, 2, 'a', r1])) + # entity_name_id_equivalency + assert not empty_diff(db.Property(value=[1, db.Record(id=2), 3, db.Record(id=4)]), + db.Property(value=[db.Record(id=1), 2, db.Record(id=3), 4])) + assert empty_diff(db.Property(value=[1, db.Record(id=2), 3, db.Record(id=4)]), + db.Property(value=[db.Record(id=1), 2, db.Record(id=3), 4]), + entity_name_id_equivalency=True) + assert empty_diff(db.Property(value=1), db.Property(value=db.Record(id=1)), + entity_name_id_equivalency=True) + # entity_name_id_equivalency + prop4 = db.Property(**prop_settings).add_parent(par1).add_property(prop2) + prop4_c = db.Property(**prop_settings).add_parent(par1).add_property(prop2) + prop4.value = db.Record(id=12) + prop4_c.value = '12' + prop4.add_property(db.Property(name="diff", datatype=db.LIST(db.REFERENCE), + value=[12, db.Record(id=13), par1, "abc%"])) + prop4_c.add_property(db.Property(name="diff", datatype=db.LIST(db.REFERENCE), + value=[db.Record(id=12), "13", par1, "abc%"])) + assert not empty_diff(prop4, prop4_c, entity_name_id_equivalency=False) + assert empty_diff(prop4, prop4_c, entity_name_id_equivalency=True) + # Order invariance + t7 = db.Property(**prop_settings).add_parent(par1).add_property(prop2) + t8 = db.Property(**alt_settings).add_parent(par3).add_property(prop3) + diffs_0 = compare_entities(t7, t8), compare_entities(t7, t8, True) + diffs_1 = compare_entities(t8, t7)[::-1], compare_entities(t8, t7, True)[::-1] + assert diffs_0 == diffs_1 + prop_settings = {"datatype": db.REFERENCE, "description": "desc of prop", + "value": db.Record().add_parent(par3), "unit": '°'} + t1.add_property(db.Property(name="description", **prop_settings)) + t2.add_property(db.Property(name="description", **prop_settings)) + # Order invariance for multi-property - either both fail or same result + try: + diffs_0 = compare_entities(t1, t2), compare_entities(t1, t2, True) + except Exception as e: + diffs_0 = type(e) + try: + diffs_1 = compare_entities(t2, t1)[::-1], compare_entities(t2, t1, True)[::-1] + except Exception as e: + diffs_1 = type(e) + assert diffs_0 == diffs_1 + # Property types + t09, t10 = db.RecordType(), db.RecordType() + for t, ex in [(db.INTEGER, [-12, 0]), (db.DATETIME, ["2030-01-01", "1012-02-29"]), + (db.DOUBLE, [13.23, 7.1]), (db.BOOLEAN, [True, False])]: + t09.add_property(db.Property(name=f"{t}:{ex[0]}", datatype=t, value=ex[0])) + t10.add_property(db.Property(name=f"{t}:{ex[0]}", datatype=t, value=ex[0])) + t09.add_property(name=f"{t}:{ex[1]}", datatype=t, value=ex[1]) + t10.add_property(name=f"{t}:{ex[1]}", datatype=t, value=ex[1]) + assert empty_diff(t09, t10) + t09.add_property(name=f"diff", value=1) + t10.add_property(name=f"diff", value=2) + assert not empty_diff(t09, t10) + # Default values + t09, t10 = db.Record(), db.Record() + t09.add_property(db.Property(name=f"A1"), value="A") + t10.add_property(name=f"A1", value="A") + t09.add_property(db.Property(id=12, name=f"A2"), value="A") + t10.add_property(id=12, name=f"A2", value="A") + t09.add_property(db.Property(id=15), value="A") + t10.add_property(id=15, value="A") + assert empty_diff(t09, t10) + # ToDo: extended tests for references + + def test_compare_special_properties(): # Test for all known special properties: - SPECIAL_PROPERTIES = ("description", "name", - "checksum", "size", "path", "id") INTS = ("size", "id") HIDDEN = ("checksum", "size") - for key in SPECIAL_PROPERTIES: + for key in SPECIAL_ATTRIBUTES: set_key = key if key in HIDDEN: set_key = "_" + key @@ -215,8 +522,7 @@ def test_compare_special_properties(): assert len(diff_r1["properties"]) == 0 assert len(diff_r2["properties"]) == 0 - -def test_compare_properties(): + # compare Property objects p1 = db.Property() p2 = db.Property() @@ -467,10 +773,10 @@ def test_empty_diff(): rec_a.remove_property("RefType") rec_b.remove_property("RefType") assert empty_diff(rec_a, rec_b) - rec_a.add_property(name="RefType", datatype=db.LIST( - "RefType"), value=[ref_rec_a, ref_rec_a]) - rec_b.add_property(name="RefType", datatype=db.LIST( - "RefType"), value=[ref_rec_b, ref_rec_b]) + rec_a.add_property(name="RefType", datatype=db.LIST("RefType"), + value=[ref_rec_a, ref_rec_a]) + rec_b.add_property(name="RefType", datatype=db.LIST("RefType"), + value=[ref_rec_b, ref_rec_b]) assert not empty_diff(rec_a, rec_b) assert empty_diff(rec_a, rec_b, compare_referenced_records=True) @@ -568,6 +874,21 @@ B: something else""" # unchanged assert recB.get_property("propA").unit == "cm" + # test whether an id is correctly overwritten by an entity without id + recA = db.Record().add_parent("A").add_property(name="B", value=112) + newRec = db.Record().add_parent("B").add_property("c") + recB = db.Record().add_parent("A").add_property(name="B", value=newRec) + + merge_entities(recA, recB, force=True) + assert recA.get_property("B").value == newRec + + recA = db.Record().add_parent("A").add_property(name="B", value=[112], + datatype=db.LIST("B")) + recB = db.Record().add_parent("A").add_property(name="B", value=[newRec], datatype=db.LIST(db.REFERENCE)) + + merge_entities(recA, recB, force=True) + assert recA.get_property("B").value == [newRec] + def test_merge_missing_list_datatype_82(): """Merging two properties, where the list-valued one has no datatype.""" @@ -601,13 +922,12 @@ def test_merge_id_with_resolved_entity(): # Overwrite from right to left in both cases merge_entities(recA, recB, merge_id_with_resolved_entity=True) - assert recA.get_property(rtname).value == ref_id - assert recA.get_property(rtname).value == recB.get_property(rtname).value + assert recA.get_property(rtname).value == ref_rec recA = db.Record().add_property(name=rtname, value=ref_rec) merge_entities(recB, recA, merge_id_with_resolved_entity=True) - assert recB.get_property(rtname).value == ref_rec - assert recA.get_property(rtname).value == recB.get_property(rtname).value + assert recB.get_property(rtname).value == ref_id + assert recA.get_property(rtname).value == ref_rec # id mismatches recB = db.Record().add_property(name=rtname, value=ref_id*2) @@ -623,7 +943,51 @@ def test_merge_id_with_resolved_entity(): # also works in lists: recA = db.Record().add_property( name=rtname, datatype=db.LIST(rtname), value=[ref_rec, ref_id*2]) - recB = db.Record().add_property(name=rtname, datatype=db.LIST(rtname), value=[ref_id, ref_id*2]) + recB = db.Record().add_property( + name=rtname, datatype=db.LIST(rtname), value=[ref_id, ref_id*2]) merge_entities(recA, recB, merge_id_with_resolved_entity=True) - assert recA.get_property(rtname).value == [ref_id, ref_id*2] - assert recA.get_property(rtname).value == recB.get_property(rtname).value + assert recA.get_property(rtname).value == [ref_rec, ref_id*2] + assert recB.get_property(rtname).value == [ref_id, ref_id*2] + + +def test_describe_diff(): + recA = db.Record() + recA.add_property(name="propA", value=2) + recA.add_property(name="propB", value=2) + recA.add_property(name="propD", value=-273, unit="K") + + recB = db.Record() + recB.add_property(name="propA", value=2) + recB.add_property(name="propB", value=12) + recB.add_property(name="propC", value="cool 17") + recB.add_property(name="propD", value=-273, unit="°C") + + diff = compare_entities(recA, recB) + diffout = describe_diff(*diff) + + assert diffout.startswith("## Difference between the first version and the second version of None") + + # The output of the describe_diff function is currently not ordered (e.g. by name of the property) + # so we cannot just compare a well-defined output string. + + assert "it does not exist in the first version:" in diffout + assert "first version: {'value': 2}" in diffout + assert "second version: {'value': 12}" in diffout + + assert "first version: {'unit': 'K'}" in diffout + assert "second version: {'unit': '°C'}" in diffout + + diffout = describe_diff(*diff, name="Entity") + assert diffout.startswith("## Difference between the first version and the second version of Entity") + + diffout = describe_diff(*diff, label_e0="recA", label_e1="recB") + assert "recA: {'value': 2}" in diffout + assert "recB: {'value': 12}" in diffout + + assert "recA: {'unit': 'K'}" in diffout + assert "recB: {'unit': '°C'}" in diffout + + assert "it does not exist in the recA:" in diffout + + assert "first" not in diffout + assert "second" not in diffout diff --git a/unittests/test_entity.py b/unittests/test_entity.py index abf82f0a9b557cf9d1d2365e01fedaa4eae0c565..2127ce028f4de55b8ef0ca704c1e69959c24ba82 100644 --- a/unittests/test_entity.py +++ b/unittests/test_entity.py @@ -22,14 +22,17 @@ # ** end header # """Tests for the Entity class.""" +import os # pylint: disable=missing-docstring import unittest -from lxml import etree -import os -from linkahead import (INTEGER, Entity, Property, Record, RecordType, +import linkahead +from linkahead import (INTEGER, Entity, Parent, Property, Record, RecordType, configure_connection) +from linkahead.common.models import SPECIAL_ATTRIBUTES from linkahead.connection.mockup import MockUpServerConnection +from lxml import etree +from pytest import raises UNITTESTDIR = os.path.dirname(os.path.abspath(__file__)) @@ -82,7 +85,13 @@ class TestEntity(unittest.TestCase): self.assertEqual(entity.to_xml().tag, "Property") def test_instantiation(self): - self.assertRaises(Exception, Entity()) + e = Entity() + for attr in SPECIAL_ATTRIBUTES: + assert hasattr(e, attr) + + def test_instantiation_bad_argument(self): + with self.assertRaises(Exception): + Entity(rol="File") def test_parse_role(self): """During parsing, the role of an entity is set explicitely. All other @@ -97,3 +106,179 @@ class TestEntity(unittest.TestCase): # test whether the __role property of this object has explicitely been # set. self.assertEqual(getattr(entity, "_Entity__role"), "Record") + + +def test_parent_list(): + p1 = RecordType(name="A") + pl = linkahead.common.models.ParentList([p1]) + assert p1 in pl + assert pl.index(p1) == 0 + assert RecordType(name="A") not in pl + assert RecordType(id=101) not in pl + p2 = RecordType(id=101) + pl.append(p2) + assert p2 in pl + assert len(pl) == 2 + p3 = RecordType(id=103, name='B') + pl.append(p3) + assert len(pl) == 3 + + # test removal + # remove by id only, even though element in parent list has name and id + pl.remove(RecordType(id=103)) + assert len(pl) == 2 + assert p3 not in pl + assert p2 in pl + assert p1 in pl + # Same for removal by name + pl.append(p3) + assert len(pl) == 3 + pl.remove(RecordType(name='B')) + assert len(pl) == 2 + assert p3 not in pl + # And an error if no suitable element can be found + with raises(KeyError) as ve: + pl.remove(RecordType(id=105, name='B')) + assert "not found" in str(ve.value) + assert len(pl) == 2 + + # TODO also check pl1 == pl2 + + +def test_property_list(): + # TODO: Resolve parent-list TODOs, then transfer to here. + # TODO: What other considerations have to be done with properties? + p1 = Property(name="A") + pl = linkahead.common.models.PropertyList() + pl.append(p1) + assert p1 in pl + assert Property(id=101) not in pl + p2 = Property(id=101) + pl.append(p2) + assert p1 in pl + assert p2 in pl + p3 = Property(id=103, name='B') + pl.append(p3) + + +def test_filter(): + rt1 = RecordType(id=100) + rt2 = RecordType(id=101, name="RT") + rt3 = RecordType(name="") + p1 = Property(id=100) + p2 = Property(id=100) + p3 = Property(id=101, name="RT") + p4 = Property(id=102, name="P") + p5 = Property(id=103, name="P") + p6 = Property(name="") + r1 = Record(id=100) + r2 = Record(id=100) + r3 = Record(id=101, name="RT") + r4 = Record(id=101, name="R") + r5 = Record(id=104, name="R") + r6 = Record(id=105, name="R") + test_ents = [rt1, rt2, rt3, p1, p2, p3, p4, p5, p6, r1, r2, r3, r4, r5, r6] + + # Setup + for entity in [Property(name=""), Record(name=""), RecordType(name="")]: + for coll in [entity.properties, entity.parents]: + for ent in test_ents: + assert ent not in coll + assert ent not in coll.filter(ent) + + # Checks with each type + t, t_props, t_pars = entity, entity.properties, entity.parents + # Properties + # Basic Checks + t.add_property(p1) + tp1 = t.properties[-1] + t.add_property(p3) + tp3 = t.properties[-1] + assert len(t_props.filter(pid=100)) == 1 + assert tp1 in t_props.filter(pid=100) + assert len(t_props.filter(pid="100")) == 1 + assert tp1 in t_props.filter(pid="100") + assert len(t_props.filter(pid=101, name="RT")) == 1 + assert tp3 in t_props.filter(pid=101, name="RT") + for entity in [rt1, p2, r1, r2]: + assert entity not in t_props.filter(pid=100) + assert tp1 in t_props.filter(entity) + # Check that direct addition (not wrapped) works + t_props.append(p2) + tp2 = t_props[-1] + assert tp2 in t_props.filter(pid=100) + assert tp2 not in t_props.filter(pid=101, name="RT") + for entity in [rt1, r1, r2]: + assert entity not in t_props.filter(pid=100) + assert tp2 in t_props.filter(entity) + + # Parents + # Filtering with both name and id + t.add_parent(r3) + tr3 = t.parents[-1] + t.add_parent(r5) + tr5 = t.parents[-1] + assert tr3 in t_pars.filter(pid=101) + assert tr5 not in t_pars.filter(pid=101) + assert tr3 not in t_pars.filter(name="R") + assert tr5 in t_pars.filter(name="R") + assert tr3 in t_pars.filter(pid=101, name="R") + assert tr5 not in t_pars.filter(pid=101, name="R") + assert tr3 not in t_pars.filter(pid=104, name="RT") + assert tr5 in t_pars.filter(pid=104, name="RT") + assert tr3 not in t_pars.filter(pid=105, name="T") + assert tr5 not in t_pars.filter(pid=105, name="T") + # Works also without id / name and with duplicate parents + for ent in test_ents: + t.add_parent(ent) + for ent in t_pars: + assert ent in t_pars.filter(ent) + + # Grid-Based + r7 = Record() + r7.add_property(Property()).add_property(name="A").add_property(name="B") + r7.add_property(id=27).add_property(id=27, name="A").add_property(id=27, name="B") + r7.add_property(id=43).add_property(id=43, name="A").add_property(id=43, name="B") + assert len(r7.properties.filter(pid=27)) == 3 + assert len(r7.properties.filter(pid=43)) == 3 + assert len(r7.properties.filter(pid=43, conjunction=True)) == 3 + assert len(r7.properties.filter(name="A")) == 3 + assert len(r7.properties.filter(name="B")) == 3 + assert len(r7.properties.filter(name="B", conjunction=True)) == 3 + assert len(r7.properties.filter(pid=1, name="A")) == 1 + assert len(r7.properties.filter(pid=1, name="A", conjunction=True)) == 0 + assert len(r7.properties.filter(pid=27, name="B")) == 4 + assert len(r7.properties.filter(pid=27, name="B", conjunction=True)) == 1 + assert len(r7.properties.filter(pid=27, name="C")) == 3 + assert len(r7.properties.filter(pid=27, name="C", conjunction=True)) == 0 + # Entity based filtering behaves the same + assert (r7.properties.filter(pid=27) == + r7.properties.filter(Property(id=27))) + assert (r7.properties.filter(pid=43, conjunction=True) == + r7.properties.filter(Property(id=43), conjunction=True)) + assert (r7.properties.filter(name="A") == + r7.properties.filter(Property(name="A"))) + assert (r7.properties.filter(name="B") == + r7.properties.filter(Property(name="B"))) + assert (r7.properties.filter(name="B", conjunction=True) == + r7.properties.filter(Property(name="B"), conjunction=True)) + assert (r7.properties.filter(pid=1, name="A") == + r7.properties.filter(Property(id=1, name="A"))) + assert (r7.properties.filter(pid=1, name="A", conjunction=True) == + r7.properties.filter(Property(id=1, name="A"), conjunction=True)) + assert (r7.properties.filter(pid=27, name="B") == + r7.properties.filter(Property(id=27, name="B"))) + assert (r7.properties.filter(pid=27, name="B", conjunction=True) == + r7.properties.filter(Property(id=27, name="B"), conjunction=True)) + assert (r7.properties.filter(pid=27, name="C") == + r7.properties.filter(Property(id=27, name="C"))) + assert (r7.properties.filter(pid=27, name="C", conjunction=True) == + r7.properties.filter(Property(id=27, name="C"), conjunction=True)) + # Name only matching and name overwrite + r8 = Record().add_property(name="A").add_property(name="B").add_property(name="B") + r8.add_property(Property(name="A"), name="B") + r8.add_property(Property(name="A", id=12), name="C") + assert len(r8.properties.filter(name="A")) == 1 + assert len(r8.properties.filter(name="B")) == 3 + assert len(r8.properties.filter(name="C")) == 1 + assert len(r8.properties.filter(pid=12)) == 1