diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a0e729877076c966b2ffa207122ea46032b2bf..33b62a8f20eadc475ab5a95b30e9a6eb2b2e345d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ## ### 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 * 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. ### Removed ### 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..17bd5af4b223b9d0db84b2124b0393e07ba2f80c 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,167 +180,276 @@ 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)): + attr0 = entity0.__getattribute__(attr) + # we consider "" and None to be nonexistent + attr0_unset = (attr0 == "" or attr0 is None) - if old_entity_attr_exists: - olddiff[attr] = oldattr + attr1 = entity1.__getattribute__(attr) + # we consider "" and None to be nonexistent + attr1_unset = (attr1 == "" or attr1 is None) - if new_entity_attr_exists: - newdiff[attr] = newattr - - # 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) + # 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.") - 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) + # 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 (olddiff, newdiff) + return diff def empty_diff(old_entity: Entity, new_entity: Entity, - compare_referenced_records: bool = False) -> bool: + compare_referenced_records: bool = False, + entity_name_id_equivalency: bool = False) -> bool: """Check whether the `compare_entities` found any differences between old_entity and new_entity. @@ -351,10 +461,13 @@ def empty_diff(old_entity: Entity, new_entity: Entity, 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. - + 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) + olddiff, newdiff = compare_entities(old_entity, new_entity, + compare_referenced_records, entity_name_id_equivalency) for diff in [olddiff, newdiff]: for key in ["parents", "properties"]: if len(diff[key]) > 0: @@ -376,9 +489,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 +499,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 +531,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 +554,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: @@ -513,6 +623,18 @@ def merge_entities(entity_a: Entity, def describe_diff(olddiff, newdiff, name=None, as_update=True): + """ + This function generates a textual representation of the differences between two entities that have been generated + using compare_entities. + + Arguments: + ---------- + olddiff: The diff output for the entity marked as "old". + newdiff: The diff output for the entity marked as "new". + + Example: + >>> describe_diff(*compare_entities(db.Record().add_property("P"), "value", db.Record())) + """ description = "" for attr in list(set(list(olddiff.keys()) + list(newdiff.keys()))): diff --git a/src/linkahead/cached.py b/src/linkahead/cached.py index f9179e1a54997bf99f7158b9b46e2d1068a21e47..11cb959ba10fd507c39eb4d1ddd00bf478859852 100644 --- a/src/linkahead/cached.py +++ b/src/linkahead/cached.py @@ -116,7 +116,7 @@ def cached_query(query_string: str) -> Container: 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 diff --git a/src/linkahead/common/models.py b/src/linkahead/common/models.py index 5689e0799f51584a39c187fd106c4d1b09e9cfc7..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 @@ -922,7 +903,7 @@ class Entity: def get_parents(self): """Get all parents of this entity. - @return: _ParentList(list) + @return: ParentList(list) """ return self.parents @@ -1022,7 +1003,7 @@ class Entity: def get_properties(self): """Get all properties of this entity. - @return: _Properties(list) + @return: PropertyList(list) """ return self.properties @@ -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/unittests/test_apiutils.py b/unittests/test_apiutils.py index 4705f19a1bdfbc4358790f787f2dce9ea97fee48..2fb946c518d940bd505622284070a0f5fafdf12f 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -25,6 +25,7 @@ # Test apiutils # A. Schlemmer, 02/2018 +from io import StringIO import linkahead as db import linkahead.apiutils @@ -96,6 +97,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 +113,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 +150,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 +244,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 +260,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 +520,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 +771,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 +872,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 +920,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 +941,8 @@ 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] 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