diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c2d071662c26322d44ed98e6e164c523edcae5af..0f9a258de99ba559d280fc5ace74a3f111a9e30e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,11 +62,11 @@ mypy: allow_failure: true # run unit tests -unittest_py3.7: +unittest_py3.8: tags: [ docker ] stage: test needs: [ ] - image: python:3.7 + image: python:3.8 script: &python_test_script # Python docker has problems with tox and pip so use plain pytest here - touch ~/.pylinkahead.ini @@ -74,13 +74,6 @@ unittest_py3.7: - pip install . - python -m pytest unittests -unittest_py3.8: - tags: [ docker ] - stage: test - needs: [ ] - image: python:3.8 - script: *python_test_script - # This needs to be changed once Python 3.9 isn't the standard Python in Debian # anymore. unittest_py3.9: @@ -121,8 +114,14 @@ unittest_py3.13: stage: test needs: [ ] image: python:3.13-rc - script: *python_test_script - + 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 # Trigger building of server image and integration tests trigger_build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 39663cc746c2ff4192d185f3fb303f62e7ef1ac5..d168b98c6e488fd99ee4670c4495e07b62ab08d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### * Support for Python 3.12 +* The `linkahead` module now opts into type checking and supports mypy. ### Changed ### @@ -17,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed ### +* Support for Python 3.7 + ### Fixed ### * [#104](https://gitlab.com/linkahead/linkahead-pylib/-/issues/104) Selecting diff --git a/Makefile b/Makefile index 21ea40ac8a6eb34032aba75c089e278fa354a6f5..9e4d30dbf8dab85892c220136466360f48d89042 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ lint: .PHONY: lint mypy: - mypy src/linkahead/common unittests + mypy src/linkahead/common unittests --exclude high_level_api.py --exclude connection.py .PHONY: mypy unittest: diff --git a/setup.py b/setup.py index 27f305c28c70dccdbf1a27fd5a2a4aa9e153f006..ee2a5fb6fd7212acfc9ce9bc732fc9f2d4f345b4 100755 --- a/setup.py +++ b/setup.py @@ -179,7 +179,7 @@ def setup_package(): "Topic :: Scientific/Engineering :: Information Analysis", ], packages=find_packages('src'), - python_requires='>=3.7', + python_requires='>=3.8', package_dir={'': 'src'}, install_requires=['lxml>=4.6.3', "requests[socks]>=2.26", diff --git a/src/doc/high_level_api.rst b/src/doc/high_level_api.rst index 5f8ae7f9b998fd1205674250383f06ae25aaf460..df9f353bf95847b01dd753d90109f2ec30ec92ba 100644 --- a/src/doc/high_level_api.rst +++ b/src/doc/high_level_api.rst @@ -18,7 +18,7 @@ Or to speak it out directly in Python: r.get_property("alpha").value = 25 # setting properties (old api) print(r.get_property("alpha").value + 25) # getting properties (old api) - from linkahead.high_level_api import convert_to_python_entity + from linkahead.high_level_api import convert_to_python_object obj = convert_to_python_object(r) # create a high level entity obj.r = 25 # setting properties (new api) print(obj.r + 25) # getting properties (new api) diff --git a/src/linkahead/common/models.py b/src/linkahead/common/models.py index 1d8e8832a3d215363af5ffcb3139d24cb6c27bc3..356a89c81fc8d91973c59e313555b188d09de7d3 100644 --- a/src/linkahead/common/models.py +++ b/src/linkahead/common/models.py @@ -54,6 +54,9 @@ if TYPE_CHECKING and sys.version_info > (3, 7): from datetime import datetime from typing import Any, Dict, Optional, Type, Union, List, TextIO, Tuple, Literal from .datatype import DATATYPE + from tempfile import _TemporaryFileWrapper + from io import BufferedWriter + from warnings import warn @@ -62,15 +65,26 @@ 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, - EntityHasNoDatatypeError, HTTPURITooLongError, - LinkAheadConnectionError, LinkAheadException, - MismatchingEntitiesError, PagingConsistencyError, - QueryNotUniqueError, TransactionError, - UniqueNamesError, UnqualifiedParentsError, - UnqualifiedPropertiesError) +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, @@ -95,7 +109,8 @@ FIX = "FIX" ALL = "ALL" NONE = "NONE" if TYPE_CHECKING: - INHERITANCE = Literal["OBLIGATORY", "SUGGESTED", "RECOMMENDED", "FIX", "ALL", "NONE"] + INHERITANCE = Literal["OBLIGATORY", "SUGGESTED", "RECOMMENDED", "ALL", "NONE"] + IMPORTANCE = Literal["OBLIGATORY", "RECOMMENDED", "SUGGESTED", "FIX", "NONE"] SPECIAL_ATTRIBUTES = ["name", "role", "datatype", "description", "id", "path", "checksum", "size", "value"] @@ -387,6 +402,10 @@ class Entity: ACL will be revoked. """ # @review Florian Spreckelsen 2022-03-17 + + if self.acl is None: + raise EntityHasNoAclError("This entity does not have an ACL (yet).") + self.acl.grant(realm=realm, username=username, role=role, permission=permission, priority=priority, revoke_denial=revoke_denial) @@ -429,6 +448,9 @@ class Entity: ACL will be revoked. """ # @review Florian Spreckelsen 2022-03-17 + if self.acl is None: + raise EntityHasNoAclError("This entity does not have an ACL (yet).") + self.acl.deny(realm=realm, username=username, role=role, permission=permission, priority=priority, revoke_grant=revoke_grant) @@ -452,12 +474,13 @@ class Entity: priority=priority) def is_permitted(self, permission: Permission, role: Optional[str] = None): - if role is None: + if role is None and self.permissions is not None: # pylint: disable=unsupported-membership-test - return permission in self.permissions - else: - self.acl.is_permitted(permission=permission) + + if self.acl is None: + raise EntityHasNoAclError("This entity does not have an ACL (yet).") + return self.acl.is_permitted(role=role, permission=permission) def get_all_messages(self) -> Messages: ret = Messages() @@ -534,20 +557,24 @@ class Entity: """ - if self.get_property(property_name) is None: + property = self.get_property(property_name) + if property is None: return self - if self.get_property(property_name).value is None: + + if property.value is None: remove_if_empty_afterwards = False + empty_afterwards = False - if isinstance(self.get_property(property_name).value, list): - if value in self.get_property(property_name).value: - self.get_property(property_name).value.remove(value) - if self.get_property(property_name).value == []: - self.get_property(property_name).value = None + if isinstance(property.value, list): + if value in property.value: + property.value.remove(value) + if property.value == []: + property.value = None empty_afterwards = True - elif self.get_property(property_name).value == value: - self.get_property(property_name).value = None + elif property.value == value: + property.value = None empty_afterwards = True + if remove_if_empty_afterwards and empty_afterwards: self.remove_property(property_name) @@ -576,10 +603,10 @@ class Entity: id: Optional[int] = None, name: Optional[str] = None, description: Optional[str] = None, - datatype: Optional[str] = None, + datatype: Optional[DATATYPE] = None, unit: Optional[str] = None, - importance: Optional[str] = None, - inheritance: Union[str, INHERITANCE, None] = None, + importance: Optional[IMPORTANCE] = None, + inheritance: Optional[INHERITANCE] = None, ) -> Entity: # @ReservedAssignment """Add a property to this entity. @@ -755,7 +782,7 @@ class Entity: parent: Union[Entity, int, str, None] = None, id: Optional[int] = None, name: Optional[str] = None, - inheritance: Union[INHERITANCE, str, None] = None, + inheritance: INHERITANCE = "NONE", ): # @ReservedAssignment """Add a parent to this entity. @@ -771,7 +798,7 @@ class Entity: Name of the parent entity. Ignored if `parent is not none`. inheritance : str, INHERITANCE - One of ``obligatory``, ``recommended``, ``suggested``, or ``fix``. Specifies the + One of ``obligatory``, ``recommended``, ``suggested``, or ``all``. Specifies the minimum importance which parent properties need to have to be inherited by this entity. If no `inheritance` is given, no properties will be inherited by the child. This parameter is case-insensitive. @@ -1018,7 +1045,7 @@ out: List[Entity] return p else: - raise ValueError("argument should be entity, int , string") + raise ValueError("`pattern` argument should be an Entity, int or str.") return None @@ -1035,8 +1062,10 @@ out: List[Entity] """ SPECIAL_SELECTORS = ["unit", "value", "description", "id", "name"] - if not isinstance(selector, (tuple, list)): + if isinstance(selector, str): selector = [selector] + elif isinstance(selector, tuple): + selector = list(selector) ref = self @@ -1051,7 +1080,7 @@ out: List[Entity] special_selector = None # iterating through the entity tree according to the selector - + prop: Optional[Property] = None for subselector in selector: # selector does not match the structure, we cannot get a # property of non-entity @@ -1078,9 +1107,11 @@ out: List[Entity] ref = prop # if we saved a special selector before, apply it - if special_selector is None: - return prop.value + if prop is None: + return None + else: + return prop.value else: return getattr(ref, special_selector.lower()) @@ -1195,7 +1226,7 @@ out: List[Entity] def to_xml( self, xml: Optional[etree._Element] = None, - add_properties: Optional[INHERITANCE] = ALL, + add_properties: INHERITANCE = "ALL", local_serialization: bool = False, ) -> etree._Element: """Generate an xml representation of this entity. If the parameter xml @@ -1207,6 +1238,10 @@ out: List[Entity] @param xml: an xml element to which all attributes, parents, properties, and messages are to be added. + + FIXME: Add documentation for the add_properties parameter. + FIXME: Add docuemntation for the local_serialization parameter. + @return: xml representation of this entity. """ @@ -1675,7 +1710,13 @@ def _log_response(body): class QueryTemplate(): - def __init__(self, id=None, name=None, query=None, description=None): # @ReservedAssignment + def __init__( + self, + id: Optional[int] = None, + name: Optional[str] = None, + query: Optional[str] = None, + description: Optional[str] = None, + ): # @ReservedAssignment self.id = (int(id) if id is not None else None) self.role = "QueryTemplate" @@ -1694,15 +1735,20 @@ class QueryTemplate(): self._size = None self._upload = None self.unit = None - self.acl = None - self.permissions = None + self.acl: Optional[ACL] = None + self.permissions: Optional[Permissions] = None self.is_valid = lambda: False self.is_deleted = lambda: False self.version = None self.state = None - def retrieve(self, raise_exception_on_error=True, unique=True, sync=True, - flags=None): + def retrieve( + self, + raise_exception_on_error: bool = True, + unique: bool = True, + sync: bool = True, + flags: Optional[Dict[str, Optional[str]]] = None, + ): return Container().append(self).retrieve( raise_exception_on_error=raise_exception_on_error, @@ -1710,8 +1756,14 @@ class QueryTemplate(): sync=sync, flags=flags)[0] - def insert(self, strict=True, raise_exception_on_error=True, - unique=True, sync=True, flags=None): + def insert( + self, + strict: bool = True, + raise_exception_on_error: bool = True, + unique: bool = True, + sync: bool = True, + flags: Optional[Dict[str, Optional[str]]] = None, + ): return Container().append(self).insert( strict=strict, @@ -1720,8 +1772,14 @@ class QueryTemplate(): sync=sync, flags=flags)[0] - def update(self, strict=True, raise_exception_on_error=True, - unique=True, sync=True, flags=None): + def update( + self, + strict: bool = True, + raise_exception_on_error: bool = True, + unique: bool = True, + sync: bool = True, + flags: Optional[Dict[str, Optional[str]]] = None, + ): return Container().append(self).update( strict=strict, @@ -1737,7 +1795,7 @@ class QueryTemplate(): def __repr__(self): return xml2str(self.to_xml()) - def to_xml(self, xml=None): + def to_xml(self, xml: Optional[etree._Element] = None): if xml is None: xml = etree.Element("QueryTemplate") @@ -1767,7 +1825,7 @@ class QueryTemplate(): return xml @staticmethod - def _from_xml(xml): + def _from_xml(xml: etree._Element): if xml.tag.lower() == "querytemplate": q = QueryTemplate(name=xml.get("name"), description=xml.get("description"), query=None) @@ -1777,16 +1835,18 @@ class QueryTemplate(): q.query = e.text else: child = _parse_single_xml_element(e) - + if child is None: + continue if isinstance(child, Message): q.messages.append(child) elif isinstance(child, ACL): q.acl = child elif isinstance(child, Version): - q.version = child + q.version = child # type: ignore elif isinstance(child, Permissions): q.permissions = child - q.id = int(xml.get("id")) + id = xml.get("id") + q.id = int(id) if id is not None else None return q else: @@ -1849,7 +1909,12 @@ class Parent(Entity): self.set_flag("inheritance", inheritance) self.__affiliation = None - def to_xml(self, xml: Optional[etree._Element] = None, add_properties=None): + def to_xml( + self, + xml: Optional[etree._Element] = None, + add_properties: INHERITANCE = "NONE", + local_serialization: bool = False, + ): if xml is None: xml = etree.Element("Parent") @@ -1919,11 +1984,20 @@ class Property(Entity): datatype=datatype, value=value, role="Property") self.unit = unit - def to_xml(self, xml: Optional[etree._Element] = None, add_properties=ALL): + def to_xml( + self, + xml: Optional[etree._Element] = None, + add_properties: INHERITANCE = "ALL", + local_serialization: bool = False, + ): if xml is None: xml = etree.Element("Property") - return super(Property, self).to_xml(xml, add_properties) + return super(Property, self).to_xml( + xml=xml, + add_properties=add_properties, + local_serialization=local_serialization, + ) def is_reference(self, server_retrieval=False): """Returns whether this Property is a reference @@ -1974,7 +2048,7 @@ class Message(object): type: Optional[str] = None, code: Optional[int] = None, description: Optional[str] = None, - body: Optional[str] = None, + body: Union[str, etree._Attrib, None] = None, ): # @ReservedAssignment self.description = description self.type = type if type is not None else "Info" @@ -2028,7 +2102,7 @@ class RecordType(Entity): parent: Union[Entity, int, str, None] = None, id: Optional[int] = None, name: Optional[str] = None, - inheritance: Union[INHERITANCE, str, None] = OBLIGATORY, + inheritance: INHERITANCE = "OBLIGATORY", ): """Add a parent to this RecordType @@ -2048,8 +2122,8 @@ class RecordType(Entity): name : str Name of the parent entity. Ignored if `parent is not none`. - inheritance : str, default OBLIGATORY - One of ``obligatory``, ``recommended``, ``suggested``, or ``fix``. Specifies the + inheritance : INHERITANCE, default OBLIGATORY + One of ``obligatory``, ``recommended``, ``suggested``, or ``all``. Specifies the minimum importance which parent properties need to have to be inherited by this entity. If no `inheritance` is given, no properties will be inherited by the child. This parameter is case-insensitive. @@ -2075,12 +2149,18 @@ class RecordType(Entity): def to_xml( self, xml: Optional[etree._Element] = None, - add_properties: Optional[INHERITANCE] = ALL, + add_properties: INHERITANCE = "ALL", + local_serialization: bool = False, ) -> etree._Element: if xml is None: xml = etree.Element("RecordType") - return Entity.to_xml(self, xml, add_properties) + return Entity.to_xml( + self, + xml=xml, + add_properties=add_properties, + local_serialization=local_serialization, + ) class Record(Entity): @@ -2104,11 +2184,20 @@ class Record(Entity): Entity.__init__(self, name=name, id=id, description=description, role="Record") - def to_xml(self, xml=None, add_properties=ALL): + def to_xml( + self, + xml: Optional[etree._Element] = None, + add_properties: INHERITANCE = "ALL", + local_serialization: bool = False, + ): if xml is None: xml = etree.Element("Record") - return Entity.to_xml(self, xml, add_properties=ALL) + return super().to_xml( + xml=xml, + add_properties=add_properties, + local_serialization=local_serialization, + ) class File(Record): @@ -2175,7 +2264,7 @@ class File(Record): def to_xml( self, xml: Optional[etree._Element] = None, - add_properties: Optional[INHERITANCE] = ALL, + add_properties: INHERITANCE = "ALL", local_serialization: bool = False, ): """Convert this file to an xml element. @@ -2189,7 +2278,7 @@ class File(Record): return Entity.to_xml(self, xml=xml, add_properties=add_properties, local_serialization=local_serialization) - def download(self, target=None): + def download(self, target: Optional[str] = None): """Download this file-entity's actual file from the file server. It will be stored to the target or will be hold as a temporary file. @@ -2199,7 +2288,7 @@ class File(Record): self.clear_server_messages() if target: - file_ = open(target, 'wb') + file_: Union[BufferedWriter, _TemporaryFileWrapper] = open(target, "wb") else: file_ = NamedTemporaryFile(mode='wb', delete=False) checksum = File.download_from_path(file_, self.path) @@ -2211,7 +2300,9 @@ class File(Record): return file_.name @staticmethod - def download_from_path(target_file, path): + def download_from_path( + target_file: Union[BufferedWriter, _TemporaryFileWrapper], path: str + ): _log_request("GET (download): " + path) response = get_connection().download_file(path) @@ -2277,23 +2368,25 @@ class _Properties(list): def __init__(self): list.__init__(self) - self._importance = dict() - self._inheritance = dict() - self._element_by_name = dict() - self._element_by_id = dict() + self._importance: Dict[Entity, IMPORTANCE] = dict() + self._inheritance: Dict[Entity, INHERITANCE] = dict() + self._element_by_name: Dict[str, Entity] = dict() + self._element_by_id: Dict[str, Entity] = dict() - def get_importance(self, property): # @ReservedAssignment + def get_importance( + self, property: Union[Property, Entity, str, None] + ): # @ReservedAssignment if property is not None: - if hasattr(property, "encode"): + if isinstance(property, str): property = self.get_by_name(property) # @ReservedAssignment return self._importance.get(property) - def set_importance(self, property, importance): # @ReservedAssignment + def set_importance(self, property: Optional[Property], importance: IMPORTANCE): # @ReservedAssignment if property is not None: self._importance[property] = importance - def get_by_name(self, name: str) -> Property: + def get_by_name(self, name: str) -> Entity: """Get a property of this list via it's name. Raises a LinkAheadException if not exactly one property has this name. @@ -2310,9 +2403,9 @@ class _Properties(list): def append( self, - property: Union[List[Entity], Entity], - importance=None, - inheritance: Union[str, INHERITANCE, None] = None, + property: Union[List[Entity], Entity, Property], + importance: Optional[IMPORTANCE] = None, + inheritance: Optional[INHERITANCE] = None, ): # @ReservedAssignment if isinstance(property, list): for p in property: @@ -2327,7 +2420,7 @@ class _Properties(list): if inheritance is not None: self._inheritance[property] = inheritance else: - self._inheritance[property] = FIX + self._inheritance[property] = "ALL" if property.id is not None: self._element_by_id[str(property.id)] = property @@ -2340,9 +2433,7 @@ class _Properties(list): return self - def to_xml( - self, add_to_element: etree._Element, add_properties: Union[str, INHERITANCE] - ): + def to_xml(self, add_to_element: etree._Element, add_properties: INHERITANCE): for p in self: importance = self._importance.get(p) @@ -3274,7 +3365,7 @@ class Container(list): # which is to be synced with which: # sync_dict[local_entity]=sync_remote_enities - sync_dict: Dict[Entity, Optional[List[Entity]]] = dict() + sync_dict: Dict[Union[Container, Entity], Optional[List[Entity]]] = dict() # list of remote entities which already have a local equivalent used_remote_entities = [] @@ -3401,7 +3492,7 @@ class Container(list): sync_remote_entities.append(remote_entity) if len(sync_remote_entities) > 0: - sync_dict[self] = sync_remote_entities + sync_dict[self] = sync_remote_entities # FIXME: How is this supposed to work? if unique and len(sync_remote_entities) != 0: msg = "Request was not unique. There are " + \ @@ -3690,7 +3781,7 @@ class Container(list): return (entities[0:hl], entities[hl:len(entities)]) - def _retrieve(self, entities, flags): + def _retrieve(self, entities, flags: Dict[str, Optional[str]]): c = get_connection() try: _log_request("GET: " + _ENTITY_URI_SEGMENT + str(entities) + @@ -3744,8 +3835,14 @@ class Container(list): return ret - def update(self, strict=False, raise_exception_on_error=True, - unique=True, sync=True, flags=None): + def update( + self, + strict: bool = False, + raise_exception_on_error: bool = True, + unique: bool = True, + sync: bool = True, + flags: Optional[Dict[str, Any]] = None, + ): """Update these entites.""" if len(self) < 1: @@ -3756,7 +3853,7 @@ class Container(list): self.clear_server_messages() insert_xml = etree.Element("Update") - http_parts = [] + http_parts: List[MultipartParam] = [] if flags is None: flags = {} @@ -3826,7 +3923,9 @@ class Container(list): return cresp @staticmethod - def _process_file_if_present_and_add_to_http_parts(http_parts, entity): + def _process_file_if_present_and_add_to_http_parts( + http_parts: List[MultipartParam], entity: Union[File, Entity] + ): if isinstance(entity, File) and hasattr( entity, 'file') and entity.file is not None: new_checksum = File._get_checksum(entity.file) @@ -3867,29 +3966,43 @@ class Container(list): else: entity._checksum = None - def insert(self, strict=False, raise_exception_on_error=True, - unique=True, sync=True, flags=None): + # FIXME: The signature of Container.insert is completely different than the superclass' + # list.insert method. This may be a problem in the future, but is ignored for now. + def insert( # type: ignore + self, + strict: bool = False, + raise_exception_on_error: bool = True, + unique: bool = True, + sync: bool = True, + flags: Optional[Dict[str, Optional[str]]] = None, + ): """Insert this file entity into LinkAhead. A successful insertion will generate a new persistent ID for this entity. This entity can be identified, retrieved, updated, and deleted via this ID until it has been deleted. - If the insertion fails, a LinkAheadException will be raised. The server will have returned at - least one error-message describing the reason why it failed in that case (call + If the insertion fails, a LinkAheadException will be raised. The server will have returned + at least one error-message describing the reason why it failed in that case (call <this_entity>.get_all_messages() in order to get these error-messages). - Some insertions might cause warning-messages on the server-side, but the entities are inserted - anyway. Set the flag 'strict' to True in order to force the server to take all warnings as errors. - This prevents the server from inserting this entity if any warning occurs. + Some insertions might cause warning-messages on the server-side, but the entities are + inserted anyway. Set the flag 'strict' to True in order to force the server to take all + warnings as errors. This prevents the server from inserting this entity if any warning + occurs. @param strict=False: Flag for strict mode. @param sync=True: synchronize this container with the response from the server. Otherwise, - this method returns a new container with the inserted entities and leaves this container untouched. + this method returns a new container with the inserted entities and leaves + this container untouched. + @param unique=True: Flag for unique mode. If set to True, the server will check if the name + of the entity is unique. If not, the server will return an error. + @param flags=None: Additional flags for the server. + """ self.clear_server_messages() insert_xml = etree.Element("Insert") - http_parts = [] + http_parts: List[MultipartParam] = [] if flags is None: flags = {} @@ -3970,7 +4083,6 @@ class Container(list): cresp = Container._response_to_entities(http_response) if sync: - self._sync(cresp, unique=unique, raise_exception_on_error=raise_exception_on_error) @@ -3985,7 +4097,7 @@ class Container(list): return cresp @staticmethod - def _get_smallest_tmpid(entity): + def _get_smallest_tmpid(entity: Entity): tmpid = 0 if entity.id is not None: @@ -4050,7 +4162,9 @@ class Container(list): return self - def get_property_values(self, *selectors): + def get_property_values( + self, *selectors: Union[str, Tuple[str]] + ) -> List[Tuple[str]]: """ Return a list of tuples with values of the given selectors. I.e. a tabular representation of the container's content. @@ -4152,11 +4266,15 @@ class ACI(): if self.role is not None: e.set("role", self.role) else: + if self.username is None: + raise LinkAheadException("An ACI must have either a role or a username.") e.set("username", self.username) if self.realm is not None: e.set("realm", self.realm) p = etree.Element("Permission") + if self.permission is None: + raise LinkAheadException("An ACI must have a permission.") p.set("name", self.permission) e.append(p) @@ -4206,7 +4324,7 @@ class ACL(): role = e.get("role") username = e.get("username") realm = e.get("realm") - priority = e.get("priority") + priority = self._get_boolean_priority(e.get("priority")) for p in e: if p.tag == "Permission": @@ -4290,8 +4408,14 @@ class ACL(): if item in self._grants: self._grants.remove(item) - def revoke_denial(self, username=None, realm=None, - role=None, permission=None, priority=False): + def revoke_denial( + self, + username: Optional[str] = None, + realm: Optional[str] = None, + role: Optional[str] = None, + permission: Optional[str] = None, + priority: bool = False, + ): priority = self._get_boolean_priority(priority) item = ACI(role=role, username=username, realm=realm, permission=permission) @@ -4452,7 +4576,7 @@ class ACL(): return ret - def get_acl_for_user(self, username, realm=None): + def get_acl_for_user(self, username: str, realm: Optional[str] = None): ret = ACL() for aci in self._grants: @@ -4531,8 +4655,8 @@ class Query(): Attributes ---------- - q : str - The query string. + q : str, etree._Element + The query string, may also be a query XML snippet. flags : dict of str A dictionary of flags to be send with the query request. messages : Messages() @@ -4545,7 +4669,7 @@ class Query(): with the resulting entities. """ - def putFlag(self, key, value=None): + def putFlag(self, key: str, value: Optional[str] = None): self.flags[key] = value return self @@ -4557,19 +4681,24 @@ class Query(): return self.flags.get(key) def __init__(self, q: Union[str, etree._Element]): - self.flags: Dict[str, str] = dict() + self.flags: Dict[str, Optional[str]] = dict() self.messages = Messages() self.cached: Optional[bool] = None self.etag = None if isinstance(q, etree._Element): - self.q = q.get("string") - self.results = int(q.get("results")) - - if q.get("cached") is None: + q.get("string") + self.q = q.get("string", "") + results = q.get("results") + if results is None: + raise LinkAheadException("The query result count is not available in the response.") + self.results = int(results) + + cached_value = q.get("cached") + if cached_value is None: self.cached = False else: - self.cached = q.get("cached").lower() == "true" + self.cached = cached_value.lower() == "true" self.etag = q.get("etag") for m in q: @@ -4578,7 +4707,7 @@ class Query(): else: self.q = q - def _query_request(self, query_dict): + def _query_request(self, query_dict: Dict[str, Optional[str]]): """Used internally to execute the query request...""" _log_request("GET Entity?" + str(query_dict), None) connection = get_connection() @@ -4588,7 +4717,12 @@ class Query(): cresp = Container._response_to_entities(http_response) return cresp - def _paging_generator(self, first_page, query_dict, page_length): + def _paging_generator( + self, + first_page: Container, + query_dict: Dict[str, Optional[str]], + page_length: int, + ): """Used internally to create a generator of pages instead instead of a container which contais all the results.""" if len(first_page) == 0: @@ -4691,7 +4825,7 @@ class Query(): return r self.messages = cresp.messages - if has_paging: + if has_paging and page_length is not None: return self._paging_generator(cresp, query_dict, page_length) else: return cresp @@ -4702,7 +4836,7 @@ def execute_query( unique: bool = False, raise_exception_on_error: bool = True, cache: bool = True, - flags: Optional[Dict[str, str]] = None, + flags: Optional[Dict[str, Optional[str]]] = None, page_length: Optional[int] = None, ) -> Union[Container, int]: """Execute a query (via a server-requests) and return the results. @@ -4806,8 +4940,8 @@ class Info(): def __init__(self): self.messages = Messages() - self.user_info = None - self.time_zone = None + self.user_info: Optional[UserInfo] = None + self.time_zone: Optional[TimeZone] = None self.sync() def sync(self): @@ -4870,7 +5004,7 @@ class Permission(): class Permissions(): - known_permissions = None + known_permissions: Optional[List[Permissions]] = None def __init__(self, xml: etree._Element): self.parse_xml(xml) @@ -4883,8 +5017,12 @@ class Permissions(): for e in xml: if e.tag == "Permission": - self._perms.add(Permission(name=e.get("name"), - description=e.get("description"))) + name = e.get("name") + if name is None: + raise LinkAheadException( + "The permission element has no name attribute." + ) + self._perms.add(Permission(name=name, description=e.get("description"))) def __contains__(self, p): if isinstance(p, Permission): @@ -4917,15 +5055,18 @@ def parse_xml(xml: Union[str, etree._Element]): def _parse_single_xml_element(elem: etree._Element): classmap = { - 'record': Record, - 'recordtype': RecordType, - 'property': Property, - 'file': File, - 'parent': Parent, - 'entity': Entity} + "record": Record, + "recordtype": RecordType, + "property": Property, + "file": File, + "parent": Parent, + "entity": Entity, + } if elem.tag.lower() in classmap: klass = classmap.get(elem.tag.lower()) + if klass is None: + raise LinkAheadException("No class for tag '{}' found.".format(elem.tag)) entity = klass() Entity._from_xml(entity, elem) @@ -4953,7 +5094,8 @@ def _parse_single_xml_element(elem: etree._Element): return Message(type='History', description=elem.get("transaction")) elif elem.tag.lower() == 'stats': counts = elem.find("counts") - + if counts is None: + raise LinkAheadException("'stats' element without a 'count' found.") return Message(type="Counts", description=None, body=counts.attrib) elif elem.tag == "EntityACL": return ACL(xml=elem) @@ -4962,14 +5104,22 @@ def _parse_single_xml_element(elem: etree._Element): elif elem.tag == "UserInfo": return UserInfo(xml=elem) elif elem.tag == "TimeZone": - return TimeZone(zone_id=elem.get("id"), offset=elem.get("offset"), - display_name=elem.text.strip()) + return TimeZone( + zone_id=elem.get("id"), + offset=elem.get("offset"), + display_name=elem.text.strip() if elem.text is not None else "", + ) else: - return Message(type=elem.tag, code=elem.get( - "code"), description=elem.get("description"), body=elem.text) + code = elem.get("code") + return Message( + type=elem.tag, + code=int(code) if code is not None else None, + description=elem.get("description"), + body=elem.text, + ) -def _evaluate_and_add_error(parent_error: TransactionError, ent: Union[Entity, Container]): +def _evaluate_and_add_error(parent_error: TransactionError, ent: Union[Entity, QueryTemplate, Container]): """Evaluate the error message(s) attached to entity and add a corresponding exception to parent_error. @@ -4978,7 +5128,7 @@ def _evaluate_and_add_error(parent_error: TransactionError, ent: Union[Entity, C parent_error : TransactionError Parent error to which the new exception will be attached. This exception will be a direct child. - ent : Entity or Container + ent : Entity or Container or QueryTemplate Entity that caused the TransactionError. An exception is created depending on its error message(s). @@ -5000,8 +5150,8 @@ def _evaluate_and_add_error(parent_error: TransactionError, ent: Union[Entity, C if err.code is not None: if int(err.code) == 101: # ent doesn't exist - new_exc = EntityDoesNotExistError(entity=ent, - error=err) + new_exc: EntityError = EntityDoesNotExistError(entity=ent, + error=err) elif int(err.code) == 110: # ent has no data type new_exc = EntityHasNoDatatypeError(entity=ent, error=err) @@ -5099,7 +5249,7 @@ def raise_errors(arg0: Union[Entity, QueryTemplate, Container]): raise transaction_error -def delete(ids: Union[List[int], range], raise_exception_on_error=True): +def delete(ids: Union[List[int], range], raise_exception_on_error: bool = True): c = Container() if isinstance(ids, list) or isinstance(ids, range): diff --git a/src/linkahead/exceptions.py b/src/linkahead/exceptions.py index a6abe09edbbece2a38bdc6c5e1296a2b3dd81bde..609d3654ac670a993185ba1faa33db921c44409c 100644 --- a/src/linkahead/exceptions.py +++ b/src/linkahead/exceptions.py @@ -354,6 +354,10 @@ class UnqualifiedPropertiesError(EntityError): """ +class EntityHasNoAclError(EntityError): + """This entity has no ACL (yet).""" + + class EntityDoesNotExistError(EntityError): """This entity does not exist.""" diff --git a/tox.ini b/tox.ini index b87f6e8140dbc431d0b190301dbfa1125e4b8ede..bbaaa1fc9eec2aba87c247d783818d215d8a7d5e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist=py37, py38, py39, py310, py311, py312, py313 +envlist=py38, py39, py310, py311, py312, py313 skip_missing_interpreters = true [testenv]