diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b792cc1fd53e5ccfb54b9d0d84ff248a3eb048d..c3858a9c521128a4182186911c90e60b71ac45b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,13 @@ 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. ### Changed ### +* `_ParentList` is now called `ParentList` +* `_Properties` is now called `PropertyList` +* `ParentList.remove` is now case insensitive when a name is used. ### Deprecated ### 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/common/models.py b/src/linkahead/common/models.py index b990e42e67e01d041b13199cd85944522a31ad11..0df706bd6e8bd1be7c7504d9de2590614f9fef27 100644 --- a/src/linkahead/common/models.py +++ b/src/linkahead/common/models.py @@ -37,8 +37,10 @@ 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 enum import Enum from datetime import date, datetime from functools import cmp_to_key from hashlib import sha512 @@ -46,7 +48,6 @@ 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 @@ -57,7 +58,6 @@ if TYPE_CHECKING: from os import PathLike QueryDict = dict[str, Optional[str]] - from warnings import warn from lxml import etree @@ -156,8 +156,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 +922,7 @@ out: bool def get_parents(self): """Get all parents of this entity. - @return: _ParentList(list) + @return: ParentList(list) """ return self.parents @@ -1022,7 +1022,7 @@ out: list[Entity] def get_properties(self): """Get all properties of this entity. - @return: _Properties(list) + @return: PropertyList(list) """ return self.properties @@ -2422,11 +2422,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 +2522,24 @@ class _Properties(list): return xml2str(xml) + def filter(self, pid:Union[str, int]=None, name:str =None, prop:Property=None): + """ + Filters all Properties from this PropertyList that match either name or ID. + + You can provide name and or ID via the corresponding arguments or you + pass a Property object to this function. + + Parameters + ---------- + pid: Union[str,int], ID of the Properties to be returned + name: str, name of the Properties to be returned + prop: Property, name of the Properties to be returned + Returns + ------- + list, a list with all matching Properties + """ + return _filter_entity_list(self, "Property", pid=pid, name=name, element=prop) + def _get_entity_by_cuid(self, cuid: str): ''' Get the first entity which has the given cuid. @@ -2576,9 +2597,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 +2612,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 +2626,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 +2670,34 @@ class _ParentList(list): return xml2str(xml) + def filter(self, pid:Union[str, int]=None, name:str =None, parent:Parent=None): + """ + Filters all Parents from this ParentList that match either name or ID. + + You can provide name and or ID via the corresponding arguments or you + pass a Parent object to this function. + + Returns + ------- + a list with all matching Parents + """ + return _filter_entity_list(self, "Parent", pid=pid, name=name, element=parent) + 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 +2715,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 +2738,19 @@ class _ParentList(list): raise KeyError(str(parent) + " not found.") +class _Properties(PropertyList): + def __init__(self, *args, **kwargs): + warnings.warn(DeprecationWarning("This class is depricated. Please use PropertyList.")) + super().__init__(*args, **kwargs) + + +class _ParentList(ParentList): + def __init__(self, *args, **kwargs): + warnings.warn(DeprecationWarning("This class is depricated. 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 +5445,30 @@ 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, element_type, pid:Union[str, int]=None, name:str =None, element:Any=None): + """ + Filterss all elements from the given list that match either name or ID. + + You can provide name and or ID via the corresponding arguments or you + pass an object to this function that has id and/or name. + + Returns + ------- + a list with all matching elements + """ + if element is not None: + if pid is not None or name is not None: + raise ValueError(f"Please provide either a {element_type} or one of " + "pid or name") + pid = element.id + name = element.name + + candidates = [] + if name is not None: + candidates.extend( + [p for p in listobject if p.name is not None and p.name.lower() == name.lower()]) + if pid is not None: + candidates.extend( + [p for p in listobject if p.id == pid]) + return candidates diff --git a/unittests/test_entity.py b/unittests/test_entity.py index d059f7e29a50161e85b2d708c05cd9e0c5254f65..1b3e9d24e3eb8c79d05bd372e18ea27e3f55ddb6 100644 --- a/unittests/test_entity.py +++ b/unittests/test_entity.py @@ -22,15 +22,17 @@ # ** end header # """Tests for the Entity class.""" +import os # pylint: disable=missing-docstring import unittest -from lxml import etree +from pytest import raises -import os -from linkahead import (INTEGER, Entity, Property, Record, RecordType, +import linkahead +from linkahead import (INTEGER, Entity, Property, Record, RecordType, Parent, configure_connection) from linkahead.common.models import SPECIAL_ATTRIBUTES from linkahead.connection.mockup import MockUpServerConnection +from lxml import etree UNITTESTDIR = os.path.dirname(os.path.abspath(__file__)) @@ -104,3 +106,75 @@ 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 + assert p1 in pl.filter(name="A") + assert p2 in pl.filter(pid=101) + assert p2 in pl.filter(pid=101, name="A") + assert p1 in pl.filter(pid=101, name="A") + assert p1 in pl.filter(parent=Parent(id=101, name="A")) + assert p2 in pl.filter(parent=Parent(id=101, name="A")) + p3 = RecordType(id=103, name='B') + pl.append(p3) + assert len(pl) == 3 + assert p3 in pl.filter(name="B") + assert p3 in pl.filter(pid=103) + + # 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) + + assert p1 in pl.filter(name="A") + assert p2 in pl.filter(pid=101) + assert p2 in pl.filter(pid=101, name="A") + assert p1 in pl.filter(pid=101, name="A") + assert p1 in pl.filter(prop=Property(id=101, name="A")) + assert p2 in pl.filter(prop=Property(id=101, name="A")) + assert p3 in pl.filter(name="B") + assert p3 in pl.filter(pid=103)